v7.1.2
This commit is contained in:
@@ -106,6 +106,22 @@ type DownloadResponse struct {
|
|||||||
ItemID string `json:"item_id,omitempty"`
|
ItemID string `json:"item_id,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func cleanupInvalidDownloadArtifacts(paths ...string) {
|
||||||
|
seen := make(map[string]struct{}, len(paths))
|
||||||
|
for _, path := range paths {
|
||||||
|
if path == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[path]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[path] = struct{}{}
|
||||||
|
if err := os.Remove(path); err == nil {
|
||||||
|
fmt.Printf("Removed invalid download artifact: %s\n", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) GetStreamingURLs(spotifyTrackID string, region string) (string, error) {
|
func (a *App) GetStreamingURLs(spotifyTrackID string, region string) (string, error) {
|
||||||
if spotifyTrackID == "" {
|
if spotifyTrackID == "" {
|
||||||
return "", fmt.Errorf("spotify track ID is required")
|
return "", fmt.Errorf("spotify track ID is required")
|
||||||
@@ -142,12 +158,27 @@ func (a *App) GetSpotifyMetadata(req SpotifyMetadataRequest) (string, error) {
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
settings, err := a.LoadSettings()
|
settings, err := a.LoadSettings()
|
||||||
|
separator := req.Separator
|
||||||
|
if separator == "" {
|
||||||
|
separator = ", "
|
||||||
|
if err == nil && settings != nil {
|
||||||
|
if sep, ok := settings["separator"].(string); ok {
|
||||||
|
if sep == "semicolon" {
|
||||||
|
separator = "; "
|
||||||
|
} else if sep == "comma" {
|
||||||
|
separator = ", "
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err == nil && settings != nil {
|
if err == nil && settings != nil {
|
||||||
if useAPI, ok := settings["useSpotFetchAPI"].(bool); ok && useAPI {
|
if useAPI, ok := settings["useSpotFetchAPI"].(bool); ok && useAPI {
|
||||||
if apiURL, ok := settings["spotFetchAPIUrl"].(string); ok && apiURL != "" {
|
if apiURL, ok := settings["spotFetchAPIUrl"].(string); ok && apiURL != "" {
|
||||||
|
|
||||||
data, err := backend.GetSpotifyDataWithAPI(ctx, req.URL, true, apiURL, req.Batch, time.Duration(req.Delay*float64(time.Second)))
|
data, err := backend.GetSpotifyDataWithAPI(ctx, req.URL, true, apiURL, req.Batch, time.Duration(req.Delay*float64(time.Second)), separator, func(tracks interface{}) {
|
||||||
|
runtime.EventsEmit(a.ctx, "metadata-stream", tracks)
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to fetch metadata from API: %v", err)
|
return "", fmt.Errorf("failed to fetch metadata from API: %v", err)
|
||||||
}
|
}
|
||||||
@@ -162,7 +193,9 @@ func (a *App) GetSpotifyMetadata(req SpotifyMetadataRequest) (string, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := backend.GetFilteredSpotifyData(ctx, req.URL, req.Batch, time.Duration(req.Delay*float64(time.Second)))
|
data, err := backend.GetFilteredSpotifyData(ctx, req.URL, req.Batch, time.Duration(req.Delay*float64(time.Second)), separator, func(tracks interface{}) {
|
||||||
|
runtime.EventsEmit(a.ctx, "metadata-stream", tracks)
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to fetch metadata: %v", err)
|
return "", fmt.Errorf("failed to fetch metadata: %v", err)
|
||||||
}
|
}
|
||||||
@@ -283,7 +316,21 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
trackURL := fmt.Sprintf("https://open.spotify.com/track/%s", req.SpotifyID)
|
trackURL := fmt.Sprintf("https://open.spotify.com/track/%s", req.SpotifyID)
|
||||||
trackData, err := backend.GetFilteredSpotifyData(ctx, trackURL, false, 0)
|
metadataSeparator := req.Separator
|
||||||
|
if metadataSeparator == "" {
|
||||||
|
metadataSeparator = ", "
|
||||||
|
metadataSettings, _ := a.LoadSettings()
|
||||||
|
if metadataSettings != nil {
|
||||||
|
if sep, ok := metadataSettings["separator"].(string); ok {
|
||||||
|
if sep == "semicolon" {
|
||||||
|
metadataSeparator = "; "
|
||||||
|
} else if sep == "comma" {
|
||||||
|
metadataSeparator = ", "
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
trackData, err := backend.GetFilteredSpotifyData(ctx, trackURL, false, 0, metadataSeparator, nil)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|
||||||
var trackResp struct {
|
var trackResp struct {
|
||||||
@@ -358,11 +405,15 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
|||||||
close(lyricsChan)
|
close(lyricsChan)
|
||||||
}
|
}
|
||||||
|
|
||||||
go func() {
|
if req.Service == "qobuz" {
|
||||||
client := backend.NewSongLinkClient()
|
go func() {
|
||||||
isrc, _ := client.GetISRC(req.SpotifyID)
|
client := backend.NewSongLinkClient()
|
||||||
isrcChan <- isrc
|
isrc, _ := client.GetISRCDirect(req.SpotifyID)
|
||||||
}()
|
isrcChan <- isrc
|
||||||
|
}()
|
||||||
|
} else {
|
||||||
|
close(isrcChan)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
close(lyricsChan)
|
close(lyricsChan)
|
||||||
close(isrcChan)
|
close(isrcChan)
|
||||||
@@ -439,6 +490,23 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
|||||||
filename = strings.TrimPrefix(filename, "EXISTS:")
|
filename = strings.TrimPrefix(filename, "EXISTS:")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !alreadyExists {
|
||||||
|
validated, validationErr := backend.ValidateDownloadedTrackDuration(filename, req.Duration)
|
||||||
|
if validationErr != nil {
|
||||||
|
cleanupInvalidDownloadArtifacts(filename)
|
||||||
|
errorMessage := validationErr.Error()
|
||||||
|
backend.FailDownloadItem(itemID, errorMessage)
|
||||||
|
return DownloadResponse{
|
||||||
|
Success: false,
|
||||||
|
Error: errorMessage,
|
||||||
|
ItemID: itemID,
|
||||||
|
}, fmt.Errorf(errorMessage)
|
||||||
|
}
|
||||||
|
if !validated {
|
||||||
|
fmt.Printf("[DownloadValidation] Skipped duration validation for %s (expected=%ds)\n", filename, req.Duration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if !alreadyExists && req.SpotifyID != "" && req.EmbedLyrics && (strings.HasSuffix(filename, ".flac") || strings.HasSuffix(filename, ".mp3") || strings.HasSuffix(filename, ".m4a")) {
|
if !alreadyExists && req.SpotifyID != "" && req.EmbedLyrics && (strings.HasSuffix(filename, ".flac") || strings.HasSuffix(filename, ".mp3") || strings.HasSuffix(filename, ".m4a")) {
|
||||||
fmt.Printf("\nWaiting for lyrics fetch to complete...\n")
|
fmt.Printf("\nWaiting for lyrics fetch to complete...\n")
|
||||||
lyrics := <-lyricsChan
|
lyrics := <-lyricsChan
|
||||||
@@ -470,6 +538,14 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
|||||||
message = "File already exists"
|
message = "File already exists"
|
||||||
backend.SkipDownloadItem(itemID, filename)
|
backend.SkipDownloadItem(itemID, filename)
|
||||||
} else {
|
} else {
|
||||||
|
if strings.EqualFold(filepath.Ext(filename), ".flac") && req.CoverURL != "" {
|
||||||
|
coverClient := backend.NewCoverClient()
|
||||||
|
if iconErr := coverClient.ApplyMacOSFLACFileIcon(filename, req.CoverURL, 256, req.EmbedMaxQualityCover); iconErr != nil {
|
||||||
|
fmt.Printf("Warning: failed to set macOS FLAC file icon: %v\n", iconErr)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("macOS FLAC file icon set: %s\n", filename)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if fileInfo, statErr := os.Stat(filename); statErr == nil {
|
if fileInfo, statErr := os.Stat(filename); statErr == nil {
|
||||||
finalSize := float64(fileInfo.Size()) / (1024 * 1024)
|
finalSize := float64(fileInfo.Size()) / (1024 * 1024)
|
||||||
@@ -479,15 +555,15 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
|||||||
backend.CompleteDownloadItem(itemID, filename, 0)
|
backend.CompleteDownloadItem(itemID, filename, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
go func(fPath, track, artist, album, sID, cover, format string) {
|
go func(fPath, track, artist, album, sID, cover, format, source string) {
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
|
||||||
quality := "Unknown"
|
quality := "Unknown"
|
||||||
durationStr := "--:--"
|
durationStr := "0:00"
|
||||||
|
|
||||||
meta, err := backend.GetTrackMetadata(fPath)
|
meta, err := backend.GetTrackMetadata(fPath)
|
||||||
if err == nil && meta != nil {
|
if err == nil {
|
||||||
if meta.BitsPerSample > 0 {
|
if meta.Bitrate > 0 {
|
||||||
quality = fmt.Sprintf("%d-bit/%.1fkHz", meta.BitsPerSample, float64(meta.SampleRate)/1000.0)
|
|
||||||
} else if meta.Bitrate > 0 {
|
|
||||||
quality = fmt.Sprintf("%dkbps/%.1fkHz", meta.Bitrate/1000, float64(meta.SampleRate)/1000.0)
|
quality = fmt.Sprintf("%dkbps/%.1fkHz", meta.Bitrate/1000, float64(meta.SampleRate)/1000.0)
|
||||||
} else if meta.SampleRate > 0 {
|
} else if meta.SampleRate > 0 {
|
||||||
quality = fmt.Sprintf("%.1fkHz", float64(meta.SampleRate)/1000.0)
|
quality = fmt.Sprintf("%.1fkHz", float64(meta.SampleRate)/1000.0)
|
||||||
@@ -508,6 +584,7 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
|||||||
Quality: quality,
|
Quality: quality,
|
||||||
Format: strings.ToUpper(format),
|
Format: strings.ToUpper(format),
|
||||||
Path: fPath,
|
Path: fPath,
|
||||||
|
Source: source,
|
||||||
}
|
}
|
||||||
|
|
||||||
if item.Format == "" || item.Format == "LOSSLESS" {
|
if item.Format == "" || item.Format == "LOSSLESS" {
|
||||||
@@ -523,7 +600,7 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
backend.AddHistoryItem(item, "SpotiFLAC")
|
backend.AddHistoryItem(item, "SpotiFLAC")
|
||||||
}(filename, req.TrackName, req.ArtistName, req.AlbumName, req.SpotifyID, req.CoverURL, req.AudioFormat)
|
}(filename, req.TrackName, req.ArtistName, req.AlbumName, req.SpotifyID, req.CoverURL, req.AudioFormat, req.Service)
|
||||||
}
|
}
|
||||||
|
|
||||||
return DownloadResponse{
|
return DownloadResponse{
|
||||||
@@ -755,46 +832,28 @@ func (a *App) ClearFetchHistoryByType(itemType string) error {
|
|||||||
return backend.ClearFetchHistoryByType(itemType, "SpotiFLAC")
|
return backend.ClearFetchHistoryByType(itemType, "SpotiFLAC")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) AnalyzeTrack(filePath string) (string, error) {
|
func (a *App) SaveSpectrumImage(audioFilePath string, base64Data string) (string, error) {
|
||||||
if filePath == "" {
|
if audioFilePath == "" || base64Data == "" {
|
||||||
return "", fmt.Errorf("file path is required")
|
return "", fmt.Errorf("file path and image data are required")
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := backend.AnalyzeTrack(filePath)
|
base64Data = strings.TrimPrefix(base64Data, "data:image/png;base64,")
|
||||||
|
|
||||||
|
data, err := base64.StdEncoding.DecodeString(base64Data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to analyze track: %v", err)
|
return "", fmt.Errorf("failed to decode base64 image: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
jsonData, err := json.Marshal(result)
|
ext := filepath.Ext(audioFilePath)
|
||||||
|
baseName := strings.TrimSuffix(filepath.Base(audioFilePath), ext)
|
||||||
|
outPath := filepath.Join(filepath.Dir(audioFilePath), baseName+".png")
|
||||||
|
|
||||||
|
err = os.WriteFile(outPath, data, 0644)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to encode response: %v", err)
|
return "", fmt.Errorf("failed to save image to disk: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return string(jsonData), nil
|
return outPath, nil
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) AnalyzeMultipleTracks(filePaths []string) (string, error) {
|
|
||||||
if len(filePaths) == 0 {
|
|
||||||
return "", fmt.Errorf("at least one file path is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
results := make([]*backend.AnalysisResult, 0, len(filePaths))
|
|
||||||
|
|
||||||
for _, filePath := range filePaths {
|
|
||||||
result, err := backend.AnalyzeTrack(filePath)
|
|
||||||
if err != nil {
|
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
results = append(results, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
jsonData, err := json.Marshal(results)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to encode response: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return string(jsonData), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type LyricsDownloadRequest struct {
|
type LyricsDownloadRequest struct {
|
||||||
@@ -1121,6 +1180,21 @@ func (a *App) ConvertAudio(req ConvertAudioRequest) ([]backend.ConvertAudioResul
|
|||||||
return backend.ConvertAudio(backendReq)
|
return backend.ConvertAudio(backendReq)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ResampleAudioRequest struct {
|
||||||
|
InputFiles []string `json:"input_files"`
|
||||||
|
SampleRate string `json:"sample_rate"`
|
||||||
|
BitDepth string `json:"bit_depth"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) ResampleAudio(req ResampleAudioRequest) ([]backend.ResampleResult, error) {
|
||||||
|
backendReq := backend.ResampleRequest{
|
||||||
|
InputFiles: req.InputFiles,
|
||||||
|
SampleRate: req.SampleRate,
|
||||||
|
BitDepth: req.BitDepth,
|
||||||
|
}
|
||||||
|
return backend.ResampleAudio(backendReq)
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) SelectAudioFiles() ([]string, error) {
|
func (a *App) SelectAudioFiles() ([]string, error) {
|
||||||
files, err := backend.SelectMultipleFiles(a.ctx)
|
files, err := backend.SelectMultipleFiles(a.ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1129,6 +1203,10 @@ func (a *App) SelectAudioFiles() ([]string, error) {
|
|||||||
return files, nil
|
return files, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) GetFlacInfoBatch(paths []string) []backend.FlacInfo {
|
||||||
|
return backend.GetFlacInfoBatch(paths)
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) GetFileSizes(files []string) map[string]int64 {
|
func (a *App) GetFileSizes(files []string) map[string]int64 {
|
||||||
return backend.GetFileSizes(files)
|
return backend.GetFileSizes(files)
|
||||||
}
|
}
|
||||||
@@ -1170,6 +1248,15 @@ func (a *App) ReadTextFile(filePath string) (string, error) {
|
|||||||
return string(content), nil
|
return string(content), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) ReadFileAsBase64(filePath string) (string, error) {
|
||||||
|
content, err := os.ReadFile(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return base64.StdEncoding.EncodeToString(content), nil
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) RenameFileTo(oldPath, newName string) error {
|
func (a *App) RenameFileTo(oldPath, newName string) error {
|
||||||
dir := filepath.Dir(oldPath)
|
dir := filepath.Dir(oldPath)
|
||||||
ext := filepath.Ext(oldPath)
|
ext := filepath.Ext(oldPath)
|
||||||
|
|||||||
+5
-60
@@ -5,7 +5,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -19,12 +18,6 @@ type AmazonDownloader struct {
|
|||||||
regions []string
|
regions []string
|
||||||
}
|
}
|
||||||
|
|
||||||
type SongLinkResponse struct {
|
|
||||||
LinksByPlatform map[string]struct {
|
|
||||||
URL string `json:"url"`
|
|
||||||
} `json:"linksByPlatform"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type AmazonStreamResponse struct {
|
type AmazonStreamResponse struct {
|
||||||
StreamURL string `json:"streamUrl"`
|
StreamURL string `json:"streamUrl"`
|
||||||
DecryptionKey string `json:"decryptionKey"`
|
DecryptionKey string `json:"decryptionKey"`
|
||||||
@@ -40,65 +33,17 @@ func NewAmazonDownloader() *AmazonDownloader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (string, error) {
|
func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (string, error) {
|
||||||
|
|
||||||
spotifyBase := "https://open.spotify.com/track/"
|
|
||||||
spotifyURL := fmt.Sprintf("%s%s", spotifyBase, spotifyTrackID)
|
|
||||||
|
|
||||||
apiBase := "https://api.song.link/v1-alpha.1/links?url="
|
|
||||||
apiURL := fmt.Sprintf("%s%s", apiBase, 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", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36")
|
|
||||||
|
|
||||||
fmt.Println("Getting Amazon URL...")
|
fmt.Println("Getting Amazon URL...")
|
||||||
|
client := NewSongLinkClient()
|
||||||
resp, err := a.client.Do(req)
|
urls, err := client.GetAllURLsFromSpotify(spotifyTrackID, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to get Amazon URL: %w", err)
|
return "", fmt.Errorf("failed to get Amazon URL: %w", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
amazonURL := normalizeAmazonMusicURL(urls.AmazonURL)
|
||||||
return "", fmt.Errorf("API returned status %d", resp.StatusCode)
|
if amazonURL == "" {
|
||||||
}
|
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to read response body: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(body) == 0 {
|
|
||||||
return "", fmt.Errorf("API returned empty response")
|
|
||||||
}
|
|
||||||
|
|
||||||
var songLinkResp SongLinkResponse
|
|
||||||
if err := json.Unmarshal(body, &songLinkResp); err != nil {
|
|
||||||
|
|
||||||
bodyStr := string(body)
|
|
||||||
if len(bodyStr) > 200 {
|
|
||||||
bodyStr = bodyStr[:200] + "..."
|
|
||||||
}
|
|
||||||
return "", fmt.Errorf("failed to decode response: %w (response: %s)", err, bodyStr)
|
|
||||||
}
|
|
||||||
|
|
||||||
amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]
|
|
||||||
if !ok || amazonLink.URL == "" {
|
|
||||||
return "", fmt.Errorf("amazon Music link not found")
|
return "", fmt.Errorf("amazon Music link not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
amazonURL := amazonLink.URL
|
|
||||||
|
|
||||||
if strings.Contains(amazonURL, "trackAsin=") {
|
|
||||||
parts := strings.Split(amazonURL, "trackAsin=")
|
|
||||||
if len(parts) > 1 {
|
|
||||||
trackAsin := strings.Split(parts[1], "&")[0]
|
|
||||||
amazonURL = fmt.Sprintf("https://music.amazon.com/tracks/%s?musicTerritory=US", trackAsin)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("Found Amazon URL: %s\n", amazonURL)
|
fmt.Printf("Found Amazon URL: %s\n", amazonURL)
|
||||||
return amazonURL, nil
|
return amazonURL, nil
|
||||||
}
|
}
|
||||||
@@ -111,7 +56,7 @@ func (a *AmazonDownloader) DownloadFromAfkarXYZ(amazonURL, outputDir, quality st
|
|||||||
return "", fmt.Errorf("failed to extract ASIN from URL: %s", amazonURL)
|
return "", fmt.Errorf("failed to extract ASIN from URL: %s", amazonURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
apiURL := fmt.Sprintf("https://amzn.afkarxyz.fun/api/track/%s", asin)
|
apiURL := fmt.Sprintf("https://amzn.afkarxyz.qzz.io/api/track/%s", asin)
|
||||||
req, err := http.NewRequest("GET", apiURL, nil)
|
req, err := http.NewRequest("GET", apiURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
|
|||||||
+21
-184
@@ -2,170 +2,26 @@ package backend
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-flac/go-flac"
|
|
||||||
mewflac "github.com/mewkiz/flac"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type AnalysisResult struct {
|
type AnalysisResult struct {
|
||||||
FilePath string `json:"file_path"`
|
FilePath string `json:"file_path"`
|
||||||
FileSize int64 `json:"file_size"`
|
FileSize int64 `json:"file_size"`
|
||||||
SampleRate uint32 `json:"sample_rate"`
|
SampleRate uint32 `json:"sample_rate"`
|
||||||
Channels uint8 `json:"channels"`
|
Channels uint8 `json:"channels"`
|
||||||
BitsPerSample uint8 `json:"bits_per_sample"`
|
BitsPerSample uint8 `json:"bits_per_sample"`
|
||||||
TotalSamples uint64 `json:"total_samples"`
|
TotalSamples uint64 `json:"total_samples"`
|
||||||
Duration float64 `json:"duration"`
|
Duration float64 `json:"duration"`
|
||||||
Bitrate int `json:"bit_rate"`
|
Bitrate int `json:"bit_rate"`
|
||||||
BitDepth string `json:"bit_depth"`
|
BitDepth string `json:"bit_depth"`
|
||||||
DynamicRange float64 `json:"dynamic_range"`
|
DynamicRange float64 `json:"dynamic_range"`
|
||||||
PeakAmplitude float64 `json:"peak_amplitude"`
|
PeakAmplitude float64 `json:"peak_amplitude"`
|
||||||
RMSLevel float64 `json:"rms_level"`
|
RMSLevel float64 `json:"rms_level"`
|
||||||
Spectrum *SpectrumData `json:"spectrum,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func AnalyzeTrack(filepath string) (*AnalysisResult, error) {
|
|
||||||
if !fileExists(filepath) {
|
|
||||||
return nil, fmt.Errorf("file does not exist: %s", filepath)
|
|
||||||
}
|
|
||||||
|
|
||||||
fileInfo, err := os.Stat(filepath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to get file info: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
f, err := flac.ParseFile(filepath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to parse FLAC file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
result := &AnalysisResult{
|
|
||||||
FilePath: filepath,
|
|
||||||
FileSize: fileInfo.Size(),
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(f.Meta) > 0 {
|
|
||||||
streamInfo := f.Meta[0]
|
|
||||||
if streamInfo.Type == flac.StreamInfo {
|
|
||||||
|
|
||||||
data := streamInfo.Data
|
|
||||||
if len(data) >= 18 {
|
|
||||||
|
|
||||||
result.SampleRate = uint32(data[10])<<12 | uint32(data[11])<<4 | uint32(data[12])>>4
|
|
||||||
|
|
||||||
result.Channels = ((data[12] >> 1) & 0x07) + 1
|
|
||||||
|
|
||||||
result.BitsPerSample = ((data[12]&0x01)<<4 | data[13]>>4) + 1
|
|
||||||
|
|
||||||
result.TotalSamples = uint64(data[13]&0x0F)<<32 |
|
|
||||||
uint64(data[14])<<24 |
|
|
||||||
uint64(data[15])<<16 |
|
|
||||||
uint64(data[16])<<8 |
|
|
||||||
uint64(data[17])
|
|
||||||
|
|
||||||
if result.SampleRate > 0 {
|
|
||||||
result.Duration = float64(result.TotalSamples) / float64(result.SampleRate)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
spectrum, err := AnalyzeSpectrum(filepath)
|
|
||||||
if err != nil {
|
|
||||||
|
|
||||||
fmt.Printf("Warning: failed to analyze spectrum: %v\n", err)
|
|
||||||
} else {
|
|
||||||
result.Spectrum = spectrum
|
|
||||||
|
|
||||||
calculateRealAudioMetrics(result, filepath)
|
|
||||||
}
|
|
||||||
|
|
||||||
result.BitDepth = fmt.Sprintf("%d-bit", result.BitsPerSample)
|
|
||||||
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func calculateRealAudioMetrics(result *AnalysisResult, filepath string) {
|
|
||||||
|
|
||||||
samples, err := decodeFLACForMetrics(filepath)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var peak float64
|
|
||||||
var sumSquares float64
|
|
||||||
|
|
||||||
for _, sample := range samples {
|
|
||||||
absVal := sample
|
|
||||||
if absVal < 0 {
|
|
||||||
absVal = -absVal
|
|
||||||
}
|
|
||||||
if absVal > peak {
|
|
||||||
peak = absVal
|
|
||||||
}
|
|
||||||
sumSquares += sample * sample
|
|
||||||
}
|
|
||||||
|
|
||||||
peakDB := 20.0 * math.Log10(peak)
|
|
||||||
result.PeakAmplitude = peakDB
|
|
||||||
|
|
||||||
rms := math.Sqrt(sumSquares / float64(len(samples)))
|
|
||||||
rmsDB := 20.0 * math.Log10(rms)
|
|
||||||
result.RMSLevel = rmsDB
|
|
||||||
|
|
||||||
result.DynamicRange = peakDB - rmsDB
|
|
||||||
}
|
|
||||||
|
|
||||||
func decodeFLACForMetrics(filepath string) ([]float64, error) {
|
|
||||||
stream, err := mewflac.ParseFile(filepath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer stream.Close()
|
|
||||||
|
|
||||||
maxSamples := 10000000
|
|
||||||
samples := make([]float64, 0, maxSamples)
|
|
||||||
|
|
||||||
for {
|
|
||||||
frame, err := stream.ParseNext()
|
|
||||||
if err != nil {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
var channelSamples []int32
|
|
||||||
if len(frame.Subframes) > 0 {
|
|
||||||
channelSamples = frame.Subframes[0].Samples
|
|
||||||
}
|
|
||||||
|
|
||||||
maxVal := float64(int64(1) << (stream.Info.BitsPerSample - 1))
|
|
||||||
for _, sample := range channelSamples {
|
|
||||||
if len(samples) >= maxSamples {
|
|
||||||
return samples, nil
|
|
||||||
}
|
|
||||||
normalized := float64(sample) / maxVal
|
|
||||||
samples = append(samples, normalized)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(samples) >= maxSamples {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return samples, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetFileSize(filepath string) (int64, error) {
|
|
||||||
info, err := os.Stat(filepath)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return info.Size(), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetTrackMetadata(filepath string) (*AnalysisResult, error) {
|
func GetTrackMetadata(filepath string) (*AnalysisResult, error) {
|
||||||
@@ -194,20 +50,23 @@ func GetMetadataWithFFprobe(filePath string) (*AnalysisResult, error) {
|
|||||||
"-v", "error",
|
"-v", "error",
|
||||||
"-select_streams", "a:0",
|
"-select_streams", "a:0",
|
||||||
"-show_entries", "stream=sample_rate,channels,bits_per_raw_sample,bits_per_sample,duration,bit_rate",
|
"-show_entries", "stream=sample_rate,channels,bits_per_raw_sample,bits_per_sample,duration,bit_rate",
|
||||||
"-of", "default=noprint_wrappers=1:nokey=1",
|
"-of", "default=noprint_wrappers=0",
|
||||||
filePath,
|
filePath,
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := exec.Command(ffprobePath, args...)
|
cmd := exec.Command(ffprobePath, args...)
|
||||||
setHideWindow(cmd)
|
setHideWindow(cmd)
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("ffprobe failed: %w - %s", err, string(output))
|
return nil, fmt.Errorf("ffprobe failed: %v - %s", err, string(output))
|
||||||
}
|
}
|
||||||
|
|
||||||
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
|
infoMap := make(map[string]string)
|
||||||
if len(lines) < 4 {
|
lines := strings.Split(string(output), "\n")
|
||||||
return nil, fmt.Errorf("unexpected ffprobe output: %s", string(output))
|
for _, line := range lines {
|
||||||
|
if strings.Contains(line, "=") {
|
||||||
|
parts := strings.SplitN(line, "=", 2)
|
||||||
|
infoMap[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
res := &AnalysisResult{
|
res := &AnalysisResult{
|
||||||
@@ -218,28 +77,6 @@ func GetMetadataWithFFprobe(filePath string) (*AnalysisResult, error) {
|
|||||||
res.FileSize = info.Size()
|
res.FileSize = info.Size()
|
||||||
}
|
}
|
||||||
|
|
||||||
infoMap := make(map[string]string)
|
|
||||||
|
|
||||||
args = []string{
|
|
||||||
"-v", "error",
|
|
||||||
"-select_streams", "a:0",
|
|
||||||
"-show_entries", "stream=sample_rate,channels,bits_per_raw_sample,bits_per_sample,duration,bit_rate",
|
|
||||||
"-of", "default=noprint_wrappers=0",
|
|
||||||
filePath,
|
|
||||||
}
|
|
||||||
cmd = exec.Command(ffprobePath, args...)
|
|
||||||
setHideWindow(cmd)
|
|
||||||
output, err = cmd.CombinedOutput()
|
|
||||||
if err == nil {
|
|
||||||
lines = strings.Split(string(output), "\n")
|
|
||||||
for _, line := range lines {
|
|
||||||
if strings.Contains(line, "=") {
|
|
||||||
parts := strings.SplitN(line, "=", 2)
|
|
||||||
infoMap[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if val, ok := infoMap["sample_rate"]; ok {
|
if val, ok := infoMap["sample_rate"]; ok {
|
||||||
s, _ := strconv.Atoi(val)
|
s, _ := strconv.Atoi(val)
|
||||||
res.SampleRate = uint32(s)
|
res.SampleRate = uint32(s)
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
package backend
|
package backend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"image/png"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
@@ -9,6 +12,9 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
xdraw "golang.org/x/image/draw"
|
||||||
|
_ "image/jpeg"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -170,6 +176,69 @@ func (c *CoverClient) DownloadCoverToPath(coverURL, outputPath string, embedMaxQ
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *CoverClient) ApplyMacOSFLACFileIcon(filePath, coverURL string, iconSize int, embedMaxQualityCover bool) error {
|
||||||
|
if filePath == "" {
|
||||||
|
return fmt.Errorf("file path is required")
|
||||||
|
}
|
||||||
|
if coverURL == "" {
|
||||||
|
return fmt.Errorf("cover URL is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpFile, err := os.CreateTemp("", "spotiflac-file-icon-*.jpg")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create temporary cover file: %w", err)
|
||||||
|
}
|
||||||
|
tmpPath := tmpFile.Name()
|
||||||
|
tmpFile.Close()
|
||||||
|
defer os.Remove(tmpPath)
|
||||||
|
|
||||||
|
if err := c.DownloadCoverToPath(coverURL, tmpPath, embedMaxQualityCover); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return SetMacOSFileIconFromImage(filePath, tmpPath, iconSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResizeImageForIcon(sourcePath string, iconSize int) (string, error) {
|
||||||
|
if sourcePath == "" {
|
||||||
|
return "", fmt.Errorf("source image path is required")
|
||||||
|
}
|
||||||
|
if iconSize <= 0 {
|
||||||
|
iconSize = 256
|
||||||
|
}
|
||||||
|
|
||||||
|
in, err := os.Open(sourcePath)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to open source image: %w", err)
|
||||||
|
}
|
||||||
|
defer in.Close()
|
||||||
|
|
||||||
|
srcImage, _, err := image.Decode(in)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to decode source image: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dst := image.NewRGBA(image.Rect(0, 0, iconSize, iconSize))
|
||||||
|
xdraw.CatmullRom.Scale(dst, dst.Bounds(), srcImage, srcImage.Bounds(), xdraw.Over, nil)
|
||||||
|
|
||||||
|
tmpFile, err := os.CreateTemp("", "spotiflac-resized-icon-*.png")
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create resized icon temp file: %w", err)
|
||||||
|
}
|
||||||
|
tmpPath := tmpFile.Name()
|
||||||
|
defer tmpFile.Close()
|
||||||
|
|
||||||
|
var encoded bytes.Buffer
|
||||||
|
if err := png.Encode(&encoded, dst); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to encode resized icon image: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := io.Copy(tmpFile, &encoded); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to write resized icon image: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return tmpPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *CoverClient) DownloadCover(req CoverDownloadRequest) (*CoverDownloadResponse, error) {
|
func (c *CoverClient) DownloadCover(req CoverDownloadRequest) (*CoverDownloadResponse, error) {
|
||||||
if req.CoverURL == "" {
|
if req.CoverURL == "" {
|
||||||
return &CoverDownloadResponse{
|
return &CoverDownloadResponse{
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package backend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
previewMaxSeconds = 35
|
||||||
|
previewExpectedMinSeconds = 60
|
||||||
|
largeMismatchMinExpected = 90
|
||||||
|
minAllowedDurationDiff = 15
|
||||||
|
durationDiffRatio = 0.25
|
||||||
|
)
|
||||||
|
|
||||||
|
func ValidateDownloadedTrackDuration(filePath string, expectedSeconds int) (bool, error) {
|
||||||
|
if filePath == "" || expectedSeconds <= 0 {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
actualDuration, err := GetAudioDuration(filePath)
|
||||||
|
if err != nil || actualDuration <= 0 {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
actualSeconds := int(math.Round(actualDuration))
|
||||||
|
if actualSeconds <= 0 {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if expectedSeconds >= previewExpectedMinSeconds && actualSeconds <= previewMaxSeconds {
|
||||||
|
return true, fmt.Errorf("detected preview/sample download: file is %ds, expected about %ds. file was removed", actualSeconds, expectedSeconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
if expectedSeconds >= largeMismatchMinExpected {
|
||||||
|
allowedDiff := int(math.Max(minAllowedDurationDiff, math.Round(float64(expectedSeconds)*durationDiffRatio)))
|
||||||
|
diff := int(math.Abs(float64(actualSeconds - expectedSeconds)))
|
||||||
|
if diff > allowedDiff {
|
||||||
|
return true, fmt.Errorf("downloaded file duration mismatch: file is %ds, expected about %ds. file was removed", actualSeconds, expectedSeconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
+7
-1
@@ -16,6 +16,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/ulikunitz/xz"
|
"github.com/ulikunitz/xz"
|
||||||
|
"golang.org/x/text/unicode/norm"
|
||||||
)
|
)
|
||||||
|
|
||||||
func ValidateExecutable(path string) error {
|
func ValidateExecutable(path string) error {
|
||||||
@@ -650,6 +651,7 @@ func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) {
|
|||||||
|
|
||||||
outputExt := "." + strings.ToLower(req.OutputFormat)
|
outputExt := "." + strings.ToLower(req.OutputFormat)
|
||||||
outputFile := filepath.Join(outputDir, baseName+outputExt)
|
outputFile := filepath.Join(outputDir, baseName+outputExt)
|
||||||
|
outputFile = norm.NFC.String(outputFile)
|
||||||
|
|
||||||
if inputExt == outputExt {
|
if inputExt == outputExt {
|
||||||
result.Error = "Input and output formats are the same"
|
result.Error = "Input and output formats are the same"
|
||||||
@@ -671,7 +673,11 @@ func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) {
|
|||||||
fmt.Printf("[FFmpeg] Warning: Failed to extract metadata from %s: %v\n", inputFile, err)
|
fmt.Printf("[FFmpeg] Warning: Failed to extract metadata from %s: %v\n", inputFile, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
coverArtPath, _ = ExtractCoverArt(inputFile)
|
inputFile = norm.NFC.String(inputFile)
|
||||||
|
coverArtPath, err = ExtractCoverArt(inputFile)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("[FFmpeg] Warning: Failed to extract cover art from %s: %v\n", inputFile, err)
|
||||||
|
}
|
||||||
lyrics, err = ExtractLyrics(inputFile)
|
lyrics, err = ExtractLyrics(inputFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("[FFmpeg] Warning: Failed to extract lyrics from %s: %v\n", inputFile, err)
|
fmt.Printf("[FFmpeg] Warning: Failed to extract lyrics from %s: %v\n", inputFile, err)
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
//go:build darwin
|
||||||
|
|
||||||
|
package backend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func SetMacOSFileIconFromImage(filePath, imagePath string, iconSize int) error {
|
||||||
|
if filePath == "" {
|
||||||
|
return fmt.Errorf("file path is required")
|
||||||
|
}
|
||||||
|
if imagePath == "" {
|
||||||
|
return fmt.Errorf("image path is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
resizedPath, err := ResizeImageForIcon(imagePath, iconSize)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer os.Remove(resizedPath)
|
||||||
|
|
||||||
|
script := `
|
||||||
|
use framework "AppKit"
|
||||||
|
on run argv
|
||||||
|
set imagePath to item 1 of argv
|
||||||
|
set targetPath to item 2 of argv
|
||||||
|
set iconImage to current application's NSImage's alloc()'s initWithContentsOfFile:imagePath
|
||||||
|
if iconImage is missing value then error "Failed to load icon image"
|
||||||
|
set didSet to (current application's NSWorkspace's sharedWorkspace()'s setIcon:iconImage forFile:targetPath options:0) as boolean
|
||||||
|
if didSet is false then error "Failed to set custom file icon"
|
||||||
|
end run
|
||||||
|
`
|
||||||
|
|
||||||
|
cmd := exec.Command("osascript", "-", resizedPath, filePath)
|
||||||
|
cmd.Stdin = strings.NewReader(script)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to apply macOS file icon: %v (%s)", err, strings.TrimSpace(string(output)))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
//go:build !darwin
|
||||||
|
|
||||||
|
package backend
|
||||||
|
|
||||||
|
func SetMacOSFileIconFromImage(filePath, imagePath string, iconSize int) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
+17
-1
@@ -50,12 +50,28 @@ func SelectFolderDialog(ctx context.Context, defaultPath string) (string, error)
|
|||||||
|
|
||||||
func SelectFileDialog(ctx context.Context) (string, error) {
|
func SelectFileDialog(ctx context.Context) (string, error) {
|
||||||
options := wailsRuntime.OpenDialogOptions{
|
options := wailsRuntime.OpenDialogOptions{
|
||||||
Title: "Select FLAC File for Analysis",
|
Title: "Select Audio File for Analysis",
|
||||||
Filters: []wailsRuntime.FileFilter{
|
Filters: []wailsRuntime.FileFilter{
|
||||||
|
{
|
||||||
|
DisplayName: "Audio Files (*.flac;*.mp3;*.m4a;*.aac)",
|
||||||
|
Pattern: "*.flac;*.mp3;*.m4a;*.aac",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
DisplayName: "FLAC Audio Files (*.flac)",
|
DisplayName: "FLAC Audio Files (*.flac)",
|
||||||
Pattern: "*.flac",
|
Pattern: "*.flac",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
DisplayName: "MP3 Audio Files (*.mp3)",
|
||||||
|
Pattern: "*.mp3",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
DisplayName: "M4A Audio Files (*.m4a)",
|
||||||
|
Pattern: "*.m4a",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
DisplayName: "AAC Audio Files (*.aac)",
|
||||||
|
Pattern: "*.aac",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
DisplayName: "All Files (*.*)",
|
DisplayName: "All Files (*.*)",
|
||||||
Pattern: "*.*",
|
Pattern: "*.*",
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ type HistoryItem struct {
|
|||||||
Quality string `json:"quality"`
|
Quality string `json:"quality"`
|
||||||
Format string `json:"format"`
|
Format string `json:"format"`
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
|
Source string `json:"source"`
|
||||||
Timestamp int64 `json:"timestamp"`
|
Timestamp int64 `json:"timestamp"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+112
-5
@@ -13,6 +13,7 @@ import (
|
|||||||
"github.com/go-flac/flacpicture"
|
"github.com/go-flac/flacpicture"
|
||||||
"github.com/go-flac/flacvorbis"
|
"github.com/go-flac/flacvorbis"
|
||||||
"github.com/go-flac/go-flac"
|
"github.com/go-flac/go-flac"
|
||||||
|
"golang.org/x/text/unicode/norm"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Metadata struct {
|
type Metadata struct {
|
||||||
@@ -218,16 +219,68 @@ func EmbedLyricsOnly(filepath string, lyrics string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ExtractCoverArt(filePath string) (string, error) {
|
func ExtractCoverArt(filePath string) (string, error) {
|
||||||
|
filePath = norm.NFC.String(filePath)
|
||||||
ext := strings.ToLower(pathfilepath.Ext(filePath))
|
ext := strings.ToLower(pathfilepath.Ext(filePath))
|
||||||
|
|
||||||
|
var coverPath string
|
||||||
|
var err error
|
||||||
|
|
||||||
switch ext {
|
switch ext {
|
||||||
case ".mp3":
|
case ".mp3":
|
||||||
return extractCoverFromMp3(filePath)
|
coverPath, err = extractCoverFromMp3(filePath)
|
||||||
case ".m4a", ".flac":
|
case ".m4a", ".flac":
|
||||||
return extractCoverFromM4AOrFlac(filePath)
|
coverPath, err = extractCoverFromM4AOrFlac(filePath)
|
||||||
default:
|
default:
|
||||||
return "", fmt.Errorf("unsupported file format: %s", ext)
|
return "", fmt.Errorf("unsupported file format: %s", ext)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err != nil || coverPath == "" {
|
||||||
|
fmt.Printf("[ExtractCoverArt] Library extraction failed for %s, trying FFmpeg fallback...\n", filePath)
|
||||||
|
ffmpegCover, ffmpegErr := extractCoverWithFFmpeg(filePath)
|
||||||
|
if ffmpegErr == nil {
|
||||||
|
return ffmpegCover, nil
|
||||||
|
}
|
||||||
|
return coverPath, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return coverPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractCoverWithFFmpeg(filePath string) (string, error) {
|
||||||
|
ffmpegPath, err := GetFFmpegPath()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpFile, err := os.CreateTemp("", "cover-*.jpg")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
tmpPath := tmpFile.Name()
|
||||||
|
tmpFile.Close()
|
||||||
|
|
||||||
|
cmd := exec.Command(ffmpegPath,
|
||||||
|
"-i", filePath,
|
||||||
|
"-an",
|
||||||
|
"-vframes", "1",
|
||||||
|
"-f", "image2",
|
||||||
|
"-update", "1",
|
||||||
|
"-y",
|
||||||
|
tmpPath,
|
||||||
|
)
|
||||||
|
|
||||||
|
setHideWindow(cmd)
|
||||||
|
if output, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
os.Remove(tmpPath)
|
||||||
|
return "", fmt.Errorf("ffmpeg cover extraction failed: %v, output: %s", err, string(output))
|
||||||
|
}
|
||||||
|
|
||||||
|
if info, err := os.Stat(tmpPath); err != nil || info.Size() == 0 {
|
||||||
|
os.Remove(tmpPath)
|
||||||
|
return "", fmt.Errorf("ffmpeg produced empty cover file")
|
||||||
|
}
|
||||||
|
|
||||||
|
return tmpPath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func extractCoverFromMp3(filePath string) (string, error) {
|
func extractCoverFromMp3(filePath string) (string, error) {
|
||||||
@@ -298,19 +351,71 @@ func extractCoverFromM4AOrFlac(filePath string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ExtractLyrics(filePath string) (string, error) {
|
func ExtractLyrics(filePath string) (string, error) {
|
||||||
|
filePath = norm.NFC.String(filePath)
|
||||||
ext := strings.ToLower(pathfilepath.Ext(filePath))
|
ext := strings.ToLower(pathfilepath.Ext(filePath))
|
||||||
|
|
||||||
|
var lyrics string
|
||||||
|
var err error
|
||||||
|
|
||||||
switch ext {
|
switch ext {
|
||||||
case ".mp3":
|
case ".mp3":
|
||||||
return extractLyricsFromMp3(filePath)
|
lyrics, err = extractLyricsFromMp3(filePath)
|
||||||
case ".flac":
|
case ".flac":
|
||||||
return extractLyricsFromFlac(filePath)
|
lyrics, err = extractLyricsFromFlac(filePath)
|
||||||
case ".m4a":
|
case ".m4a":
|
||||||
|
|
||||||
return "", nil
|
return "", nil
|
||||||
default:
|
default:
|
||||||
return "", fmt.Errorf("unsupported file format: %s", ext)
|
return "", fmt.Errorf("unsupported file format: %s", ext)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (err != nil || lyrics == "") && ext != ".m4a" {
|
||||||
|
fmt.Printf("[ExtractLyrics] Library extraction failed for %s, trying ffprobe fallback...\n", filePath)
|
||||||
|
ffprobeLyrics, ffprobeErr := extractLyricsWithFFprobe(filePath)
|
||||||
|
if ffprobeErr == nil && ffprobeLyrics != "" {
|
||||||
|
return ffprobeLyrics, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lyrics, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractLyricsWithFFprobe(filePath string) (string, error) {
|
||||||
|
ffprobePath, err := GetFFprobePath()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command(ffprobePath,
|
||||||
|
"-v", "quiet",
|
||||||
|
"-show_entries", "format_tags=lyrics:format_tags=unsyncedlyrics:format_tags=lyric",
|
||||||
|
"-of", "json",
|
||||||
|
filePath,
|
||||||
|
)
|
||||||
|
|
||||||
|
setHideWindow(cmd)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Format struct {
|
||||||
|
Tags map[string]string `json:"tags"`
|
||||||
|
} `json:"format"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(output, &result); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
tags := result.Format.Tags
|
||||||
|
for _, key := range []string{"lyrics", "unsyncedlyrics", "lyric", "LYRICS", "UNSYNCEDLYRICS", "LYRIC"} {
|
||||||
|
if val, ok := tags[key]; ok && val != "" {
|
||||||
|
return val, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func extractLyricsFromMp3(filePath string) (string, error) {
|
func extractLyricsFromMp3(filePath string) (string, error) {
|
||||||
@@ -688,6 +793,7 @@ func parseLRCTimestamp(timestamp string) int64 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ExtractFullMetadataFromFile(filePath string) (Metadata, error) {
|
func ExtractFullMetadataFromFile(filePath string) (Metadata, error) {
|
||||||
|
filePath = norm.NFC.String(filePath)
|
||||||
var metadata Metadata
|
var metadata Metadata
|
||||||
|
|
||||||
ffprobePath, err := GetFFprobePath()
|
ffprobePath, err := GetFFprobePath()
|
||||||
@@ -796,6 +902,7 @@ func ExtractFullMetadataFromFile(filePath string) (Metadata, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func EmbedMetadataToConvertedFile(filePath string, metadata Metadata, coverPath string) error {
|
func EmbedMetadataToConvertedFile(filePath string, metadata Metadata, coverPath string) error {
|
||||||
|
filePath = norm.NFC.String(filePath)
|
||||||
ext := strings.ToLower(pathfilepath.Ext(filePath))
|
ext := strings.ToLower(pathfilepath.Ext(filePath))
|
||||||
|
|
||||||
switch ext {
|
switch ext {
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ func FetchMusicBrainzMetadata(isrc, title, artist, album string, useSingleGenre
|
|||||||
return meta, err
|
return meta, err
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Header.Set("User-Agent", fmt.Sprintf("SpotiFLAC/%s ( support@exyezed.cc )", AppVersion))
|
req.Header.Set("User-Agent", fmt.Sprintf("SpotiFLAC/%s ( hi@afkarxyz.qzz.io )", AppVersion))
|
||||||
|
|
||||||
var resp *http.Response
|
var resp *http.Response
|
||||||
var lastErr error
|
var lastErr error
|
||||||
|
|||||||
+3
-3
@@ -119,7 +119,7 @@ func (q *QobuzDownloader) searchByISRC(isrc string) (*QobuzTrack, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func buildQobuzAPIURL(apiBase string, trackID int64, quality string) string {
|
func buildQobuzAPIURL(apiBase string, trackID int64, quality string) string {
|
||||||
if strings.Contains(apiBase, "qbz.afkarxyz.fun") {
|
if strings.Contains(apiBase, "qbz.afkarxyz.qzz.io") {
|
||||||
return fmt.Sprintf("%s%d?quality=%s", apiBase, trackID, quality)
|
return fmt.Sprintf("%s%d?quality=%s", apiBase, trackID, quality)
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("%s%d&quality=%s", apiBase, trackID, quality)
|
return fmt.Sprintf("%s%d&quality=%s", apiBase, trackID, quality)
|
||||||
@@ -174,7 +174,7 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string, allowFal
|
|||||||
standardAPIs := []string{
|
standardAPIs := []string{
|
||||||
"https://dab.yeet.su/api/stream?trackId=",
|
"https://dab.yeet.su/api/stream?trackId=",
|
||||||
"https://dabmusic.xyz/api/stream?trackId=",
|
"https://dabmusic.xyz/api/stream?trackId=",
|
||||||
"https://qbz.afkarxyz.fun/api/track/",
|
"https://qbz.afkarxyz.qzz.io/api/track/",
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadFunc := func(qual string) (string, error) {
|
downloadFunc := func(qual string) (string, error) {
|
||||||
@@ -365,7 +365,7 @@ func (q *QobuzDownloader) DownloadTrack(spotifyID, outputDir, quality, filenameF
|
|||||||
var deezerISRC string
|
var deezerISRC string
|
||||||
if spotifyID != "" {
|
if spotifyID != "" {
|
||||||
songlinkClient := NewSongLinkClient()
|
songlinkClient := NewSongLinkClient()
|
||||||
isrc, err := songlinkClient.GetISRC(spotifyID)
|
isrc, err := songlinkClient.GetISRCDirect(spotifyID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to get ISRC: %v", err)
|
return "", fmt.Errorf("failed to get ISRC: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
package backend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
type FlacInfo struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
SampleRate uint32 `json:"sample_rate"`
|
||||||
|
BitsPerSample uint8 `json:"bits_per_sample"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetFlacInfoBatch(paths []string) []FlacInfo {
|
||||||
|
results := make([]FlacInfo, len(paths))
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
for i, path := range paths {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(idx int, p string) {
|
||||||
|
defer wg.Done()
|
||||||
|
info := FlacInfo{Path: p}
|
||||||
|
|
||||||
|
ffprobePath, err := GetFFprobePath()
|
||||||
|
if err != nil {
|
||||||
|
results[idx] = info
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
args := []string{
|
||||||
|
"-v", "error",
|
||||||
|
"-select_streams", "a:0",
|
||||||
|
"-show_entries", "stream=sample_rate,bits_per_raw_sample,bits_per_sample",
|
||||||
|
"-of", "default=noprint_wrappers=0",
|
||||||
|
p,
|
||||||
|
}
|
||||||
|
cmd := exec.Command(ffprobePath, args...)
|
||||||
|
setHideWindow(cmd)
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
results[idx] = info
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
kvMap := make(map[string]string)
|
||||||
|
for _, line := range strings.Split(string(out), "\n") {
|
||||||
|
if parts := strings.SplitN(line, "=", 2); len(parts) == 2 {
|
||||||
|
kvMap[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if v, ok := kvMap["sample_rate"]; ok {
|
||||||
|
if s, err := strconv.Atoi(v); err == nil {
|
||||||
|
info.SampleRate = uint32(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bits := 0
|
||||||
|
if v, ok := kvMap["bits_per_raw_sample"]; ok && v != "N/A" && v != "" {
|
||||||
|
bits, _ = strconv.Atoi(v)
|
||||||
|
}
|
||||||
|
if bits == 0 {
|
||||||
|
if v, ok := kvMap["bits_per_sample"]; ok && v != "N/A" && v != "" {
|
||||||
|
bits, _ = strconv.Atoi(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
info.BitsPerSample = uint8(bits)
|
||||||
|
|
||||||
|
results[idx] = info
|
||||||
|
}(i, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResampleRequest struct {
|
||||||
|
InputFiles []string `json:"input_files"`
|
||||||
|
SampleRate string `json:"sample_rate"`
|
||||||
|
BitDepth string `json:"bit_depth"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResampleResult struct {
|
||||||
|
InputFile string `json:"input_file"`
|
||||||
|
OutputFile string `json:"output_file"`
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildFolderLabel(sampleRate, bitDepth string) string {
|
||||||
|
var parts []string
|
||||||
|
|
||||||
|
if bitDepth != "" {
|
||||||
|
parts = append(parts, bitDepth+"bit")
|
||||||
|
}
|
||||||
|
|
||||||
|
switch sampleRate {
|
||||||
|
case "44100":
|
||||||
|
parts = append(parts, "44.1kHz")
|
||||||
|
case "48000":
|
||||||
|
parts = append(parts, "48kHz")
|
||||||
|
case "96000":
|
||||||
|
parts = append(parts, "96kHz")
|
||||||
|
case "192000":
|
||||||
|
parts = append(parts, "192kHz")
|
||||||
|
default:
|
||||||
|
if sampleRate != "" {
|
||||||
|
parts = append(parts, sampleRate+"Hz")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(parts) == 0 {
|
||||||
|
return "Resampled"
|
||||||
|
}
|
||||||
|
return strings.Join(parts, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResampleAudio(req ResampleRequest) ([]ResampleResult, error) {
|
||||||
|
ffmpegPath, err := GetFFmpegPath()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get ffmpeg path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ValidateExecutable(ffmpegPath); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid ffmpeg executable: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
installed, err := IsFFmpegInstalled()
|
||||||
|
if err != nil || !installed {
|
||||||
|
return nil, fmt.Errorf("ffmpeg is not installed")
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.SampleRate == "" && req.BitDepth == "" {
|
||||||
|
return nil, fmt.Errorf("at least one of sample rate or bit depth must be specified")
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make([]ResampleResult, len(req.InputFiles))
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
var mu sync.Mutex
|
||||||
|
|
||||||
|
folderLabel := buildFolderLabel(req.SampleRate, req.BitDepth)
|
||||||
|
|
||||||
|
for i, inputFile := range req.InputFiles {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(idx int, inputFile string) {
|
||||||
|
defer wg.Done()
|
||||||
|
|
||||||
|
result := ResampleResult{
|
||||||
|
InputFile: inputFile,
|
||||||
|
}
|
||||||
|
|
||||||
|
inputExt := strings.ToLower(filepath.Ext(inputFile))
|
||||||
|
baseName := strings.TrimSuffix(filepath.Base(inputFile), inputExt)
|
||||||
|
inputDir := filepath.Dir(inputFile)
|
||||||
|
|
||||||
|
outputDir := filepath.Join(inputDir, folderLabel)
|
||||||
|
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||||
|
result.Error = fmt.Sprintf("failed to create output directory: %v", err)
|
||||||
|
result.Success = false
|
||||||
|
mu.Lock()
|
||||||
|
results[idx] = result
|
||||||
|
mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
outputFile := filepath.Join(outputDir, baseName+".flac")
|
||||||
|
result.OutputFile = outputFile
|
||||||
|
|
||||||
|
args := []string{
|
||||||
|
"-i", inputFile,
|
||||||
|
"-y",
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.BitDepth != "" {
|
||||||
|
switch req.BitDepth {
|
||||||
|
case "16":
|
||||||
|
args = append(args, "-c:a", "flac", "-sample_fmt", "s16")
|
||||||
|
case "24":
|
||||||
|
args = append(args, "-c:a", "flac", "-sample_fmt", "s32", "-bits_per_raw_sample", "24")
|
||||||
|
default:
|
||||||
|
args = append(args, "-c:a", "flac")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
args = append(args, "-c:a", "flac")
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.SampleRate != "" {
|
||||||
|
args = append(args, "-ar", req.SampleRate)
|
||||||
|
}
|
||||||
|
|
||||||
|
args = append(args, "-map_metadata", "0")
|
||||||
|
args = append(args, outputFile)
|
||||||
|
|
||||||
|
fmt.Printf("[Resample] %s -> %s\n", inputFile, outputFile)
|
||||||
|
|
||||||
|
cmd := exec.Command(ffmpegPath, args...)
|
||||||
|
setHideWindow(cmd)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
result.Error = fmt.Sprintf("resampling failed: %s - %s", err.Error(), string(output))
|
||||||
|
result.Success = false
|
||||||
|
mu.Lock()
|
||||||
|
results[idx] = result
|
||||||
|
mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Success = true
|
||||||
|
fmt.Printf("[Resample] Done: %s\n", outputFile)
|
||||||
|
mu.Lock()
|
||||||
|
results[idx] = result
|
||||||
|
mu.Unlock()
|
||||||
|
}(i, inputFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
+778
-318
File diff suppressed because it is too large
Load Diff
@@ -1,181 +0,0 @@
|
|||||||
package backend
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"math"
|
|
||||||
"math/cmplx"
|
|
||||||
|
|
||||||
"github.com/mewkiz/flac"
|
|
||||||
)
|
|
||||||
|
|
||||||
type SpectrumData struct {
|
|
||||||
TimeSlices []TimeSlice `json:"time_slices"`
|
|
||||||
SampleRate int `json:"sample_rate"`
|
|
||||||
FreqBins int `json:"freq_bins"`
|
|
||||||
Duration float64 `json:"duration"`
|
|
||||||
MaxFreq float64 `json:"max_freq"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type TimeSlice struct {
|
|
||||||
Time float64 `json:"time"`
|
|
||||||
Magnitudes []float64 `json:"magnitudes"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func AnalyzeSpectrum(filepath string) (*SpectrumData, error) {
|
|
||||||
|
|
||||||
stream, err := flac.ParseFile(filepath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to parse FLAC: %w", err)
|
|
||||||
}
|
|
||||||
defer stream.Close()
|
|
||||||
|
|
||||||
info := stream.Info
|
|
||||||
sampleRate := int(info.SampleRate)
|
|
||||||
channels := int(info.NChannels)
|
|
||||||
|
|
||||||
samples, err := readSamples(stream, channels)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to read samples: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(samples) == 0 {
|
|
||||||
return nil, fmt.Errorf("no audio samples found")
|
|
||||||
}
|
|
||||||
|
|
||||||
return calculateSpectrum(samples, sampleRate), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func readSamples(stream *flac.Stream, channels int) ([]float64, error) {
|
|
||||||
var allSamples []float64
|
|
||||||
maxSamples := 10 * 1024 * 1024
|
|
||||||
|
|
||||||
for {
|
|
||||||
frame, err := stream.ParseNext()
|
|
||||||
if err != nil {
|
|
||||||
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := 0; i < frame.Subframes[0].NSamples; i++ {
|
|
||||||
var sample float64
|
|
||||||
|
|
||||||
for ch := 0; ch < channels; ch++ {
|
|
||||||
sample += float64(frame.Subframes[ch].Samples[i])
|
|
||||||
}
|
|
||||||
sample /= float64(channels)
|
|
||||||
|
|
||||||
allSamples = append(allSamples, sample)
|
|
||||||
|
|
||||||
if len(allSamples) >= maxSamples {
|
|
||||||
return allSamples, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return allSamples, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func calculateSpectrum(samples []float64, sampleRate int) *SpectrumData {
|
|
||||||
fftSize := 8192
|
|
||||||
numTimeSlices := 300
|
|
||||||
|
|
||||||
duration := float64(len(samples)) / float64(sampleRate)
|
|
||||||
|
|
||||||
samplesPerSlice := len(samples) / numTimeSlices
|
|
||||||
if samplesPerSlice < fftSize {
|
|
||||||
samplesPerSlice = fftSize
|
|
||||||
numTimeSlices = len(samples) / fftSize
|
|
||||||
}
|
|
||||||
|
|
||||||
timeSlices := make([]TimeSlice, 0, numTimeSlices)
|
|
||||||
freqBins := fftSize / 2
|
|
||||||
maxFreq := float64(sampleRate) / 2.0
|
|
||||||
|
|
||||||
for i := 0; i < numTimeSlices; i++ {
|
|
||||||
startIdx := i * samplesPerSlice
|
|
||||||
if startIdx+fftSize > len(samples) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
window := samples[startIdx : startIdx+fftSize]
|
|
||||||
|
|
||||||
windowedSamples := applyHannWindow(window)
|
|
||||||
|
|
||||||
spectrum := fft(windowedSamples)
|
|
||||||
|
|
||||||
magnitudes := make([]float64, freqBins)
|
|
||||||
for j := 0; j < freqBins; j++ {
|
|
||||||
magnitude := cmplx.Abs(spectrum[j])
|
|
||||||
|
|
||||||
if magnitude < 1e-10 {
|
|
||||||
magnitude = 1e-10
|
|
||||||
}
|
|
||||||
magnitudes[j] = 20 * math.Log10(magnitude)
|
|
||||||
}
|
|
||||||
|
|
||||||
timeSlice := TimeSlice{
|
|
||||||
Time: float64(startIdx) / float64(sampleRate),
|
|
||||||
Magnitudes: magnitudes,
|
|
||||||
}
|
|
||||||
timeSlices = append(timeSlices, timeSlice)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &SpectrumData{
|
|
||||||
TimeSlices: timeSlices,
|
|
||||||
SampleRate: sampleRate,
|
|
||||||
FreqBins: freqBins,
|
|
||||||
Duration: duration,
|
|
||||||
MaxFreq: maxFreq,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func applyHannWindow(samples []float64) []float64 {
|
|
||||||
n := len(samples)
|
|
||||||
windowed := make([]float64, n)
|
|
||||||
|
|
||||||
for i := 0; i < n; i++ {
|
|
||||||
window := 0.5 * (1.0 - math.Cos(2.0*math.Pi*float64(i)/float64(n-1)))
|
|
||||||
windowed[i] = samples[i] * window
|
|
||||||
}
|
|
||||||
|
|
||||||
return windowed
|
|
||||||
}
|
|
||||||
|
|
||||||
func fft(samples []float64) []complex128 {
|
|
||||||
n := len(samples)
|
|
||||||
|
|
||||||
x := make([]complex128, n)
|
|
||||||
for i := 0; i < n; i++ {
|
|
||||||
x[i] = complex(samples[i], 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
return fftRecursive(x)
|
|
||||||
}
|
|
||||||
|
|
||||||
func fftRecursive(x []complex128) []complex128 {
|
|
||||||
n := len(x)
|
|
||||||
|
|
||||||
if n <= 1 {
|
|
||||||
return x
|
|
||||||
}
|
|
||||||
|
|
||||||
even := make([]complex128, n/2)
|
|
||||||
odd := make([]complex128, n/2)
|
|
||||||
|
|
||||||
for i := 0; i < n/2; i++ {
|
|
||||||
even[i] = x[2*i]
|
|
||||||
odd[i] = x[2*i+1]
|
|
||||||
}
|
|
||||||
|
|
||||||
evenFFT := fftRecursive(even)
|
|
||||||
oddFFT := fftRecursive(odd)
|
|
||||||
|
|
||||||
result := make([]complex128, n)
|
|
||||||
for k := 0; k < n/2; k++ {
|
|
||||||
t := cmplx.Exp(complex(0, -2*math.Pi*float64(k)/float64(n))) * oddFFT[k]
|
|
||||||
result[k] = evenFFT[k] + t
|
|
||||||
result[k+n/2] = evenFFT[k] - t
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
+21
-33
@@ -485,7 +485,7 @@ func extractDuration(ms float64) map[string]interface{} {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func FilterTrack(data map[string]interface{}, albumFetchData ...map[string]interface{}) map[string]interface{} {
|
func FilterTrack(data map[string]interface{}, separator string, albumFetchData ...map[string]interface{}) map[string]interface{} {
|
||||||
dataMap := getMap(data, "data")
|
dataMap := getMap(data, "data")
|
||||||
trackData := getMap(dataMap, "trackUnion")
|
trackData := getMap(dataMap, "trackUnion")
|
||||||
if len(trackData) == 0 {
|
if len(trackData) == 0 {
|
||||||
@@ -555,7 +555,7 @@ func FilterTrack(data map[string]interface{}, albumFetchData ...map[string]inter
|
|||||||
copyrightData := getMap(albumData, "copyright")
|
copyrightData := getMap(albumData, "copyright")
|
||||||
if len(copyrightData) > 0 {
|
if len(copyrightData) > 0 {
|
||||||
copyrightItems := getSlice(copyrightData, "items")
|
copyrightItems := getSlice(copyrightData, "items")
|
||||||
if copyrightItems != nil {
|
if len(copyrightItems) > 0 {
|
||||||
for _, item := range copyrightItems {
|
for _, item := range copyrightItems {
|
||||||
itemMap, ok := item.(map[string]interface{})
|
itemMap, ok := item.(map[string]interface{})
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -574,7 +574,7 @@ func FilterTrack(data map[string]interface{}, albumFetchData ...map[string]inter
|
|||||||
if len(tracksData) > 0 {
|
if len(tracksData) > 0 {
|
||||||
discNumbers := make(map[int]bool)
|
discNumbers := make(map[int]bool)
|
||||||
trackItems := getSlice(tracksData, "items")
|
trackItems := getSlice(tracksData, "items")
|
||||||
if trackItems != nil {
|
if len(trackItems) > 0 {
|
||||||
for _, item := range trackItems {
|
for _, item := range trackItems {
|
||||||
itemMap, ok := item.(map[string]interface{})
|
itemMap, ok := item.(map[string]interface{})
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -656,7 +656,7 @@ func FilterTrack(data map[string]interface{}, albumFetchData ...map[string]inter
|
|||||||
|
|
||||||
albumArtistsString := ""
|
albumArtistsString := ""
|
||||||
albumLabel := ""
|
albumLabel := ""
|
||||||
if albumFetchDataMap != nil && len(albumFetchDataMap) > 0 {
|
if len(albumFetchDataMap) > 0 {
|
||||||
albumUnionData := getMap(getMap(albumFetchDataMap, "data"), "albumUnion")
|
albumUnionData := getMap(getMap(albumFetchDataMap, "data"), "albumUnion")
|
||||||
if len(albumUnionData) > 0 {
|
if len(albumUnionData) > 0 {
|
||||||
albumArtists := extractArtists(getMap(albumUnionData, "artists"))
|
albumArtists := extractArtists(getMap(albumUnionData, "artists"))
|
||||||
@@ -665,7 +665,7 @@ func FilterTrack(data map[string]interface{}, albumFetchData ...map[string]inter
|
|||||||
for _, artist := range albumArtists {
|
for _, artist := range albumArtists {
|
||||||
albumArtistNames = append(albumArtistNames, getString(artist, "name"))
|
albumArtistNames = append(albumArtistNames, getString(artist, "name"))
|
||||||
}
|
}
|
||||||
albumArtistsString = strings.Join(albumArtistNames, GetSeparator())
|
albumArtistsString = strings.Join(albumArtistNames, separator)
|
||||||
}
|
}
|
||||||
if albumArtistsString == "" {
|
if albumArtistsString == "" {
|
||||||
albumArtistsString = getString(albumUnionData, "artists")
|
albumArtistsString = getString(albumUnionData, "artists")
|
||||||
@@ -681,7 +681,7 @@ func FilterTrack(data map[string]interface{}, albumFetchData ...map[string]inter
|
|||||||
for _, artist := range albumArtists {
|
for _, artist := range albumArtists {
|
||||||
albumArtistNames = append(albumArtistNames, getString(artist, "name"))
|
albumArtistNames = append(albumArtistNames, getString(artist, "name"))
|
||||||
}
|
}
|
||||||
albumArtistsString = strings.Join(albumArtistNames, GetSeparator())
|
albumArtistsString = strings.Join(albumArtistNames, separator)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -715,7 +715,7 @@ func FilterTrack(data map[string]interface{}, albumFetchData ...map[string]inter
|
|||||||
for _, artist := range artists {
|
for _, artist := range artists {
|
||||||
artistNames = append(artistNames, getString(artist, "name"))
|
artistNames = append(artistNames, getString(artist, "name"))
|
||||||
}
|
}
|
||||||
artistsString := strings.Join(artistNames, GetSeparator())
|
artistsString := strings.Join(artistNames, separator)
|
||||||
|
|
||||||
copyrightTexts := []string{}
|
copyrightTexts := []string{}
|
||||||
for _, item := range copyrightInfo {
|
for _, item := range copyrightInfo {
|
||||||
@@ -802,7 +802,7 @@ func FilterTrack(data map[string]interface{}, albumFetchData ...map[string]inter
|
|||||||
return filtered
|
return filtered
|
||||||
}
|
}
|
||||||
|
|
||||||
func FilterAlbum(data map[string]interface{}) map[string]interface{} {
|
func FilterAlbum(data map[string]interface{}, separator string) map[string]interface{} {
|
||||||
dataMap := getMap(data, "data")
|
dataMap := getMap(data, "data")
|
||||||
albumData := getMap(dataMap, "albumUnion")
|
albumData := getMap(dataMap, "albumUnion")
|
||||||
if len(albumData) == 0 {
|
if len(albumData) == 0 {
|
||||||
@@ -814,7 +814,7 @@ func FilterAlbum(data map[string]interface{}) map[string]interface{} {
|
|||||||
for _, artist := range artists {
|
for _, artist := range artists {
|
||||||
artistNames = append(artistNames, getString(artist, "name"))
|
artistNames = append(artistNames, getString(artist, "name"))
|
||||||
}
|
}
|
||||||
albumArtistsString := strings.Join(artistNames, GetSeparator())
|
albumArtistsString := strings.Join(artistNames, separator)
|
||||||
|
|
||||||
coverObj := extractCoverImage(getMap(albumData, "coverArt"))
|
coverObj := extractCoverImage(getMap(albumData, "coverArt"))
|
||||||
var cover interface{}
|
var cover interface{}
|
||||||
@@ -875,7 +875,7 @@ func FilterAlbum(data map[string]interface{}) map[string]interface{} {
|
|||||||
for _, artist := range trackArtists {
|
for _, artist := range trackArtists {
|
||||||
trackArtistNames = append(trackArtistNames, getString(artist, "name"))
|
trackArtistNames = append(trackArtistNames, getString(artist, "name"))
|
||||||
}
|
}
|
||||||
trackArtistsString := strings.Join(trackArtistNames, GetSeparator())
|
trackArtistsString := strings.Join(trackArtistNames, separator)
|
||||||
|
|
||||||
trackURI := getString(track, "uri")
|
trackURI := getString(track, "uri")
|
||||||
trackID := ""
|
trackID := ""
|
||||||
@@ -943,7 +943,7 @@ func FilterAlbum(data map[string]interface{}) map[string]interface{} {
|
|||||||
return filtered
|
return filtered
|
||||||
}
|
}
|
||||||
|
|
||||||
func FilterPlaylist(data map[string]interface{}) map[string]interface{} {
|
func FilterPlaylist(data map[string]interface{}, separator string) map[string]interface{} {
|
||||||
dataMap := getMap(data, "data")
|
dataMap := getMap(data, "data")
|
||||||
playlistData := getMap(dataMap, "playlistV2")
|
playlistData := getMap(dataMap, "playlistV2")
|
||||||
if len(playlistData) == 0 {
|
if len(playlistData) == 0 {
|
||||||
@@ -957,21 +957,9 @@ func FilterPlaylist(data map[string]interface{}) map[string]interface{} {
|
|||||||
avatarData := getMap(ownerData, "avatar")
|
avatarData := getMap(ownerData, "avatar")
|
||||||
if len(avatarData) > 0 {
|
if len(avatarData) > 0 {
|
||||||
sources := getSlice(avatarData, "sources")
|
sources := getSlice(avatarData, "sources")
|
||||||
if sources != nil {
|
if len(sources) > 0 {
|
||||||
for _, source := range sources {
|
if firstSource, ok := sources[0].(map[string]interface{}); ok {
|
||||||
sourceMap, ok := source.(map[string]interface{})
|
avatarURL = getString(firstSource, "url")
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if getFloat64(sourceMap, "width") == 300 {
|
|
||||||
avatarURL = getString(sourceMap, "url")
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if avatarURL == nil && len(sources) > 0 {
|
|
||||||
if firstSource, ok := sources[0].(map[string]interface{}); ok {
|
|
||||||
avatarURL = getString(firstSource, "url")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1075,7 +1063,7 @@ func FilterPlaylist(data map[string]interface{}) map[string]interface{} {
|
|||||||
for _, artist := range trackArtists {
|
for _, artist := range trackArtists {
|
||||||
trackArtistNames = append(trackArtistNames, getString(artist, "name"))
|
trackArtistNames = append(trackArtistNames, getString(artist, "name"))
|
||||||
}
|
}
|
||||||
artistsString := strings.Join(trackArtistNames, GetSeparator())
|
artistsString := strings.Join(trackArtistNames, separator)
|
||||||
|
|
||||||
trackDurationMs := getFloat64(getMap(trackData, "trackDuration"), "totalMilliseconds")
|
trackDurationMs := getFloat64(getMap(trackData, "trackDuration"), "totalMilliseconds")
|
||||||
durationObj := extractDuration(trackDurationMs)
|
durationObj := extractDuration(trackDurationMs)
|
||||||
@@ -1121,7 +1109,7 @@ func FilterPlaylist(data map[string]interface{}) map[string]interface{} {
|
|||||||
for _, artist := range albumArtists {
|
for _, artist := range albumArtists {
|
||||||
albumArtistNames = append(albumArtistNames, getString(artist, "name"))
|
albumArtistNames = append(albumArtistNames, getString(artist, "name"))
|
||||||
}
|
}
|
||||||
albumArtistsString = strings.Join(albumArtistNames, GetSeparator())
|
albumArtistsString = strings.Join(albumArtistNames, separator)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1291,11 +1279,11 @@ func extractDiscographyItems(itemsData map[string]interface{}) []map[string]inte
|
|||||||
}
|
}
|
||||||
|
|
||||||
func stripHTMLTags(s string) string {
|
func stripHTMLTags(s string) string {
|
||||||
re := regexp.MustCompile(`<[^>]*>`)
|
re := regexp.MustCompile(`(?s)<[^>]*>`)
|
||||||
return re.ReplaceAllString(s, "")
|
return re.ReplaceAllString(s, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
func FilterArtist(data map[string]interface{}) map[string]interface{} {
|
func FilterArtist(data map[string]interface{}, separator string) map[string]interface{} {
|
||||||
dataMap := getMap(data, "data")
|
dataMap := getMap(data, "data")
|
||||||
artistData := getMap(dataMap, "artistUnion")
|
artistData := getMap(dataMap, "artistUnion")
|
||||||
if len(artistData) == 0 {
|
if len(artistData) == 0 {
|
||||||
@@ -1424,7 +1412,7 @@ func FilterArtist(data map[string]interface{}) map[string]interface{} {
|
|||||||
return filtered
|
return filtered
|
||||||
}
|
}
|
||||||
|
|
||||||
func FilterSearch(data map[string]interface{}) map[string]interface{} {
|
func FilterSearch(data map[string]interface{}, separator string) map[string]interface{} {
|
||||||
dataMap := getMap(data, "data")
|
dataMap := getMap(data, "data")
|
||||||
searchData := getMap(dataMap, "searchV2")
|
searchData := getMap(dataMap, "searchV2")
|
||||||
if len(searchData) == 0 {
|
if len(searchData) == 0 {
|
||||||
@@ -1514,7 +1502,7 @@ func FilterSearch(data map[string]interface{}) map[string]interface{} {
|
|||||||
for _, artist := range trackArtists {
|
for _, artist := range trackArtists {
|
||||||
trackArtistNames = append(trackArtistNames, getString(artist, "name"))
|
trackArtistNames = append(trackArtistNames, getString(artist, "name"))
|
||||||
}
|
}
|
||||||
trackArtistsString := strings.Join(trackArtistNames, GetSeparator())
|
trackArtistsString := strings.Join(trackArtistNames, separator)
|
||||||
|
|
||||||
durationString := getString(trackDuration, "formatted")
|
durationString := getString(trackDuration, "formatted")
|
||||||
|
|
||||||
@@ -1586,7 +1574,7 @@ func FilterSearch(data map[string]interface{}) map[string]interface{} {
|
|||||||
for _, artist := range albumArtists {
|
for _, artist := range albumArtists {
|
||||||
albumArtistNames = append(albumArtistNames, getString(artist, "name"))
|
albumArtistNames = append(albumArtistNames, getString(artist, "name"))
|
||||||
}
|
}
|
||||||
albumArtistsString := strings.Join(albumArtistNames, GetSeparator())
|
albumArtistsString := strings.Join(albumArtistNames, separator)
|
||||||
|
|
||||||
dateInfo := getMap(album, "date")
|
dateInfo := getMap(album, "date")
|
||||||
var year interface{}
|
var year interface{}
|
||||||
|
|||||||
@@ -11,10 +11,37 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func GetSpotifyDataWithAPI(ctx context.Context, spotifyURL string, useAPI bool, apiBaseURL string, batch bool, delay time.Duration) (interface{}, error) {
|
func streamTrackListChunks(ctx context.Context, tracks []AlbumTrackMetadata, callback MetadataCallback) error {
|
||||||
if !useAPI || apiBaseURL == "" {
|
if callback == nil || len(tracks) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
return GetFilteredSpotifyData(ctx, spotifyURL, batch, delay)
|
const chunkSize = 25
|
||||||
|
for start := 0; start < len(tracks); start += chunkSize {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
end := start + chunkSize
|
||||||
|
if end > len(tracks) {
|
||||||
|
end = len(tracks)
|
||||||
|
}
|
||||||
|
|
||||||
|
callback(tracks[start:end])
|
||||||
|
|
||||||
|
if end < len(tracks) {
|
||||||
|
time.Sleep(15 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetSpotifyDataWithAPI(ctx context.Context, spotifyURL string, useAPI bool, apiBaseURL string, batch bool, delay time.Duration, separator string, callback MetadataCallback) (interface{}, error) {
|
||||||
|
if !useAPI || apiBaseURL == "" {
|
||||||
|
return GetFilteredSpotifyData(ctx, spotifyURL, batch, delay, separator, callback)
|
||||||
}
|
}
|
||||||
|
|
||||||
spotifyType, id := parseSpotifyURLToTypeAndID(spotifyURL)
|
spotifyType, id := parseSpotifyURLToTypeAndID(spotifyURL)
|
||||||
@@ -22,6 +49,10 @@ func GetSpotifyDataWithAPI(ctx context.Context, spotifyURL string, useAPI bool,
|
|||||||
return nil, fmt.Errorf("invalid Spotify URL: %s", spotifyURL)
|
return nil, fmt.Errorf("invalid Spotify URL: %s", spotifyURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if spotifyType == "artist" {
|
||||||
|
return GetFilteredSpotifyData(ctx, spotifyURL, batch, delay, separator, callback)
|
||||||
|
}
|
||||||
|
|
||||||
apiURL := fmt.Sprintf("%s/%s/%s", strings.TrimSuffix(apiBaseURL, "/"), spotifyType, id)
|
apiURL := fmt.Sprintf("%s/%s/%s", strings.TrimSuffix(apiBaseURL, "/"), spotifyType, id)
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
|
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
|
||||||
@@ -63,22 +94,75 @@ func GetSpotifyDataWithAPI(ctx context.Context, spotifyURL string, useAPI bool,
|
|||||||
return nil, fmt.Errorf("failed to decode album response: %w", err)
|
return nil, fmt.Errorf("failed to decode album response: %w", err)
|
||||||
}
|
}
|
||||||
data = &albumResp
|
data = &albumResp
|
||||||
|
if callback != nil {
|
||||||
|
callback(&AlbumResponsePayload{
|
||||||
|
AlbumInfo: albumResp.AlbumInfo,
|
||||||
|
TrackList: []AlbumTrackMetadata{},
|
||||||
|
})
|
||||||
|
if err := streamTrackListChunks(ctx, albumResp.TrackList, callback); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
case "playlist":
|
case "playlist":
|
||||||
var playlistResp PlaylistResponsePayload
|
var playlistResp PlaylistResponsePayload
|
||||||
if err := json.Unmarshal(bodyBytes, &playlistResp); err != nil {
|
if err := json.Unmarshal(bodyBytes, &playlistResp); err != nil {
|
||||||
return nil, fmt.Errorf("failed to decode playlist response: %w", err)
|
return nil, fmt.Errorf("failed to decode playlist response: %w", err)
|
||||||
}
|
}
|
||||||
data = playlistResp
|
data = playlistResp
|
||||||
|
if callback != nil {
|
||||||
|
callback(PlaylistResponsePayload{
|
||||||
|
PlaylistInfo: playlistResp.PlaylistInfo,
|
||||||
|
TrackList: []AlbumTrackMetadata{},
|
||||||
|
})
|
||||||
|
if err := streamTrackListChunks(ctx, playlistResp.TrackList, callback); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
case "artist":
|
case "artist":
|
||||||
var artistResp ArtistDiscographyPayload
|
var artistResp ArtistDiscographyPayload
|
||||||
if err := json.Unmarshal(bodyBytes, &artistResp); err != nil {
|
if err := json.Unmarshal(bodyBytes, &artistResp); err != nil {
|
||||||
return nil, fmt.Errorf("failed to decode artist response: %w", err)
|
return nil, fmt.Errorf("failed to decode artist response: %w", err)
|
||||||
}
|
}
|
||||||
data = &artistResp
|
data = &artistResp
|
||||||
|
if callback != nil {
|
||||||
|
callback(&ArtistDiscographyPayload{
|
||||||
|
ArtistInfo: artistResp.ArtistInfo,
|
||||||
|
AlbumList: artistResp.AlbumList,
|
||||||
|
TrackList: []AlbumTrackMetadata{},
|
||||||
|
})
|
||||||
|
if err := streamTrackListChunks(ctx, artistResp.TrackList, callback); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("unsupported Spotify type: %s", spotifyType)
|
return nil, fmt.Errorf("unsupported Spotify type: %s", spotifyType)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if callback != nil {
|
||||||
|
switch payload := data.(type) {
|
||||||
|
case TrackResponse:
|
||||||
|
t := payload.Track
|
||||||
|
callback([]AlbumTrackMetadata{{
|
||||||
|
SpotifyID: t.SpotifyID,
|
||||||
|
Artists: t.Artists,
|
||||||
|
Name: t.Name,
|
||||||
|
AlbumName: t.AlbumName,
|
||||||
|
AlbumArtist: t.AlbumArtist,
|
||||||
|
DurationMS: t.DurationMS,
|
||||||
|
Images: t.Images,
|
||||||
|
ReleaseDate: t.ReleaseDate,
|
||||||
|
TrackNumber: t.TrackNumber,
|
||||||
|
TotalTracks: t.TotalTracks,
|
||||||
|
DiscNumber: t.DiscNumber,
|
||||||
|
TotalDiscs: t.TotalDiscs,
|
||||||
|
ExternalURL: t.ExternalURL,
|
||||||
|
Plays: t.Plays,
|
||||||
|
PreviewURL: t.PreviewURL,
|
||||||
|
IsExplicit: t.IsExplicit,
|
||||||
|
}})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return data, nil
|
return data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+100
-30
@@ -18,13 +18,17 @@ var (
|
|||||||
errInvalidSpotifyURL = errors.New("invalid or unsupported Spotify URL")
|
errInvalidSpotifyURL = errors.New("invalid or unsupported Spotify URL")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type MetadataCallback func(data interface{})
|
||||||
|
|
||||||
type SpotifyMetadataClient struct {
|
type SpotifyMetadataClient struct {
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
|
Separator string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSpotifyMetadataClient() *SpotifyMetadataClient {
|
func NewSpotifyMetadataClient() *SpotifyMetadataClient {
|
||||||
return &SpotifyMetadataClient{
|
return &SpotifyMetadataClient{
|
||||||
httpClient: &http.Client{Timeout: 30 * time.Second},
|
httpClient: &http.Client{Timeout: 30 * time.Second},
|
||||||
|
Separator: ", ",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -342,54 +346,57 @@ type SearchResponse struct {
|
|||||||
Playlists []SearchResult `json:"playlists"`
|
Playlists []SearchResult `json:"playlists"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetFilteredSpotifyData(ctx context.Context, spotifyURL string, batch bool, delay time.Duration) (interface{}, error) {
|
func GetFilteredSpotifyData(ctx context.Context, spotifyURL string, batch bool, delay time.Duration, separator string, callback MetadataCallback) (interface{}, error) {
|
||||||
client := NewSpotifyMetadataClient()
|
client := NewSpotifyMetadataClient()
|
||||||
return client.GetFilteredData(ctx, spotifyURL, batch, delay)
|
if separator != "" {
|
||||||
|
client.Separator = separator
|
||||||
|
}
|
||||||
|
return client.GetFilteredData(ctx, spotifyURL, batch, delay, callback)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *SpotifyMetadataClient) GetFilteredData(ctx context.Context, spotifyURL string, batch bool, delay time.Duration) (interface{}, error) {
|
func (c *SpotifyMetadataClient) GetFilteredData(ctx context.Context, spotifyURL string, batch bool, delay time.Duration, callback MetadataCallback) (interface{}, error) {
|
||||||
parsed, err := parseSpotifyURI(spotifyURL)
|
parsed, err := parseSpotifyURI(spotifyURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
raw, err := c.getRawSpotifyData(ctx, parsed, batch, delay)
|
raw, err := c.getRawSpotifyData(ctx, parsed, batch, delay, callback)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.processSpotifyData(ctx, raw)
|
return c.processSpotifyData(ctx, raw, callback)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *SpotifyMetadataClient) getRawSpotifyData(ctx context.Context, parsed spotifyURI, batch bool, delay time.Duration) (interface{}, error) {
|
func (c *SpotifyMetadataClient) getRawSpotifyData(ctx context.Context, parsed spotifyURI, batch bool, delay time.Duration, callback MetadataCallback) (interface{}, error) {
|
||||||
switch parsed.Type {
|
switch parsed.Type {
|
||||||
case "playlist":
|
case "playlist":
|
||||||
return c.fetchPlaylist(ctx, parsed.ID)
|
return c.fetchPlaylist(ctx, parsed.ID, callback)
|
||||||
case "album":
|
case "album":
|
||||||
return c.fetchAlbum(ctx, parsed.ID)
|
return c.fetchAlbum(ctx, parsed.ID, callback)
|
||||||
case "track":
|
case "track":
|
||||||
return c.fetchTrack(ctx, parsed.ID)
|
return c.fetchTrack(ctx, parsed.ID)
|
||||||
case "artist_discography":
|
case "artist_discography":
|
||||||
return c.fetchArtistDiscography(ctx, parsed)
|
return c.fetchArtistDiscography(ctx, parsed, callback)
|
||||||
case "artist":
|
case "artist":
|
||||||
|
|
||||||
discographyParsed := spotifyURI{Type: "artist_discography", ID: parsed.ID, DiscographyGroup: "all"}
|
discographyParsed := spotifyURI{Type: "artist_discography", ID: parsed.ID, DiscographyGroup: "all"}
|
||||||
return c.fetchArtistDiscography(ctx, discographyParsed)
|
return c.fetchArtistDiscography(ctx, discographyParsed, callback)
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("unsupported Spotify type: %s", parsed.Type)
|
return nil, fmt.Errorf("unsupported Spotify type: %s", parsed.Type)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *SpotifyMetadataClient) processSpotifyData(ctx context.Context, raw interface{}) (interface{}, error) {
|
func (c *SpotifyMetadataClient) processSpotifyData(ctx context.Context, raw interface{}, callback MetadataCallback) (interface{}, error) {
|
||||||
switch payload := raw.(type) {
|
switch payload := raw.(type) {
|
||||||
case *apiPlaylistResponse:
|
case *apiPlaylistResponse:
|
||||||
return c.formatPlaylistData(payload), nil
|
return c.formatPlaylistData(payload, callback), nil
|
||||||
case *apiAlbumResponse:
|
case *apiAlbumResponse:
|
||||||
return c.formatAlbumData(payload)
|
return c.formatAlbumData(payload, callback)
|
||||||
case *apiTrackResponse:
|
case *apiTrackResponse:
|
||||||
return c.formatTrackData(payload), nil
|
return c.formatTrackData(payload), nil
|
||||||
case *apiArtistResponse:
|
case *apiArtistResponse:
|
||||||
return c.formatArtistDiscographyData(ctx, payload)
|
return c.formatArtistDiscographyData(ctx, payload, callback)
|
||||||
default:
|
default:
|
||||||
return nil, errors.New("unknown raw payload type")
|
return nil, errors.New("unknown raw payload type")
|
||||||
}
|
}
|
||||||
@@ -437,7 +444,7 @@ func (c *SpotifyMetadataClient) fetchTrack(ctx context.Context, trackID string)
|
|||||||
|
|
||||||
if albumID != "" {
|
if albumID != "" {
|
||||||
|
|
||||||
albumResponse, err := c.fetchAlbumWithClient(ctx, client, albumID)
|
albumResponse, err := c.fetchAlbumWithClient(ctx, client, albumID, nil)
|
||||||
if err == nil && albumResponse != nil {
|
if err == nil && albumResponse != nil {
|
||||||
|
|
||||||
albumJSON, _ := json.Marshal(albumResponse)
|
albumJSON, _ := json.Marshal(albumResponse)
|
||||||
@@ -482,7 +489,7 @@ func (c *SpotifyMetadataClient) fetchTrack(ctx context.Context, trackID string)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
filteredData := FilterTrack(data, albumFetchData)
|
filteredData := FilterTrack(data, c.Separator, albumFetchData)
|
||||||
|
|
||||||
jsonData, err := json.Marshal(filteredData)
|
jsonData, err := json.Marshal(filteredData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -497,15 +504,15 @@ func (c *SpotifyMetadataClient) fetchTrack(ctx context.Context, trackID string)
|
|||||||
return &result, nil
|
return &result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID string) (*apiAlbumResponse, error) {
|
func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID string, callback MetadataCallback) (*apiAlbumResponse, error) {
|
||||||
client := NewSpotifyClient()
|
client := NewSpotifyClient()
|
||||||
if err := client.Initialize(); err != nil {
|
if err := client.Initialize(); err != nil {
|
||||||
return nil, fmt.Errorf("failed to initialize spotify client: %w", err)
|
return nil, fmt.Errorf("failed to initialize spotify client: %w", err)
|
||||||
}
|
}
|
||||||
return c.fetchAlbumWithClient(ctx, client, albumID)
|
return c.fetchAlbumWithClient(ctx, client, albumID, callback)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *SpotifyMetadataClient) fetchAlbumWithClient(ctx context.Context, client *SpotifyClient, albumID string) (*apiAlbumResponse, error) {
|
func (c *SpotifyMetadataClient) fetchAlbumWithClient(ctx context.Context, client *SpotifyClient, albumID string, callback MetadataCallback) (*apiAlbumResponse, error) {
|
||||||
|
|
||||||
allItems := []interface{}{}
|
allItems := []interface{}{}
|
||||||
offset := 0
|
offset := 0
|
||||||
@@ -537,6 +544,15 @@ func (c *SpotifyMetadataClient) fetchAlbumWithClient(ctx context.Context, client
|
|||||||
|
|
||||||
if data == nil {
|
if data == nil {
|
||||||
data = response
|
data = response
|
||||||
|
if callback != nil {
|
||||||
|
filtered := FilterAlbum(data, c.Separator)
|
||||||
|
jsonData, _ := json.Marshal(filtered)
|
||||||
|
var result apiAlbumResponse
|
||||||
|
if json.Unmarshal(jsonData, &result) == nil {
|
||||||
|
formatted, _ := c.formatAlbumData(&result, nil)
|
||||||
|
callback(formatted)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
albumData := getMap(getMap(response, "data"), "albumUnion")
|
albumData := getMap(getMap(response, "data"), "albumUnion")
|
||||||
@@ -579,7 +595,7 @@ func (c *SpotifyMetadataClient) fetchAlbumWithClient(ctx context.Context, client
|
|||||||
tracksV2["totalCount"] = len(allItems)
|
tracksV2["totalCount"] = len(allItems)
|
||||||
}
|
}
|
||||||
|
|
||||||
filteredData := FilterAlbum(data)
|
filteredData := FilterAlbum(data, c.Separator)
|
||||||
|
|
||||||
jsonData, err := json.Marshal(filteredData)
|
jsonData, err := json.Marshal(filteredData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -594,7 +610,7 @@ func (c *SpotifyMetadataClient) fetchAlbumWithClient(ctx context.Context, client
|
|||||||
return &result, nil
|
return &result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID string) (*apiPlaylistResponse, error) {
|
func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID string, callback MetadataCallback) (*apiPlaylistResponse, error) {
|
||||||
client := NewSpotifyClient()
|
client := NewSpotifyClient()
|
||||||
if err := client.Initialize(); err != nil {
|
if err := client.Initialize(); err != nil {
|
||||||
return nil, fmt.Errorf("failed to initialize spotify client: %w", err)
|
return nil, fmt.Errorf("failed to initialize spotify client: %w", err)
|
||||||
@@ -630,6 +646,15 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID st
|
|||||||
|
|
||||||
if data == nil {
|
if data == nil {
|
||||||
data = response
|
data = response
|
||||||
|
if callback != nil {
|
||||||
|
filtered := FilterPlaylist(data, c.Separator)
|
||||||
|
jsonData, _ := json.Marshal(filtered)
|
||||||
|
var result apiPlaylistResponse
|
||||||
|
if json.Unmarshal(jsonData, &result) == nil {
|
||||||
|
formatted := c.formatPlaylistData(&result, nil)
|
||||||
|
callback(formatted)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
playlistData := getMap(getMap(response, "data"), "playlistV2")
|
playlistData := getMap(getMap(response, "data"), "playlistV2")
|
||||||
@@ -672,7 +697,7 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID st
|
|||||||
content["totalCount"] = len(allItems)
|
content["totalCount"] = len(allItems)
|
||||||
}
|
}
|
||||||
|
|
||||||
filteredData := FilterPlaylist(data)
|
filteredData := FilterPlaylist(data, c.Separator)
|
||||||
|
|
||||||
jsonData, err := json.Marshal(filteredData)
|
jsonData, err := json.Marshal(filteredData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -687,7 +712,7 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID st
|
|||||||
return &result, nil
|
return &result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *SpotifyMetadataClient) fetchArtistDiscography(ctx context.Context, parsed spotifyURI) (*apiArtistResponse, error) {
|
func (c *SpotifyMetadataClient) fetchArtistDiscography(ctx context.Context, parsed spotifyURI, callback MetadataCallback) (*apiArtistResponse, error) {
|
||||||
client := NewSpotifyClient()
|
client := NewSpotifyClient()
|
||||||
if err := client.Initialize(); err != nil {
|
if err := client.Initialize(); err != nil {
|
||||||
return nil, fmt.Errorf("failed to initialize spotify client: %w", err)
|
return nil, fmt.Errorf("failed to initialize spotify client: %w", err)
|
||||||
@@ -712,6 +737,16 @@ func (c *SpotifyMetadataClient) fetchArtistDiscography(ctx context.Context, pars
|
|||||||
return nil, fmt.Errorf("failed to query artist overview: %w", err)
|
return nil, fmt.Errorf("failed to query artist overview: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if callback != nil {
|
||||||
|
filtered := FilterArtist(data, c.Separator)
|
||||||
|
jsonData, _ := json.Marshal(filtered)
|
||||||
|
var result apiArtistResponse
|
||||||
|
if json.Unmarshal(jsonData, &result) == nil {
|
||||||
|
formatted, _ := c.formatArtistDiscographyData(ctx, &result, nil)
|
||||||
|
callback(formatted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
allDiscographyItems := []interface{}{}
|
allDiscographyItems := []interface{}{}
|
||||||
offset := 0
|
offset := 0
|
||||||
limit := 50
|
limit := 50
|
||||||
@@ -841,7 +876,7 @@ func (c *SpotifyMetadataClient) fetchArtistDiscography(ctx context.Context, pars
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
filteredData := FilterArtist(data)
|
filteredData := FilterArtist(data, c.Separator)
|
||||||
|
|
||||||
jsonData, err := json.Marshal(filteredData)
|
jsonData, err := json.Marshal(filteredData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -898,7 +933,7 @@ func (c *SpotifyMetadataClient) formatTrackData(raw *apiTrackResponse) TrackResp
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *SpotifyMetadataClient) formatAlbumData(raw *apiAlbumResponse) (*AlbumResponsePayload, error) {
|
func (c *SpotifyMetadataClient) formatAlbumData(raw *apiAlbumResponse, callback MetadataCallback) (*AlbumResponsePayload, error) {
|
||||||
var artistID, artistURL string
|
var artistID, artistURL string
|
||||||
|
|
||||||
info := AlbumInfoMetadata{
|
info := AlbumInfoMetadata{
|
||||||
@@ -911,6 +946,13 @@ func (c *SpotifyMetadataClient) formatAlbumData(raw *apiAlbumResponse) (*AlbumRe
|
|||||||
ArtistURL: artistURL,
|
ArtistURL: artistURL,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if callback != nil {
|
||||||
|
callback(AlbumResponsePayload{
|
||||||
|
AlbumInfo: info,
|
||||||
|
TrackList: []AlbumTrackMetadata{},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
tracks := make([]AlbumTrackMetadata, 0, len(raw.Tracks))
|
tracks := make([]AlbumTrackMetadata, 0, len(raw.Tracks))
|
||||||
for idx, item := range raw.Tracks {
|
for idx, item := range raw.Tracks {
|
||||||
durationMS := parseDuration(item.Duration)
|
durationMS := parseDuration(item.Duration)
|
||||||
@@ -955,13 +997,17 @@ func (c *SpotifyMetadataClient) formatAlbumData(raw *apiAlbumResponse) (*AlbumRe
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if callback != nil {
|
||||||
|
callback(tracks)
|
||||||
|
}
|
||||||
|
|
||||||
return &AlbumResponsePayload{
|
return &AlbumResponsePayload{
|
||||||
AlbumInfo: info,
|
AlbumInfo: info,
|
||||||
TrackList: tracks,
|
TrackList: tracks,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *SpotifyMetadataClient) formatPlaylistData(raw *apiPlaylistResponse) PlaylistResponsePayload {
|
func (c *SpotifyMetadataClient) formatPlaylistData(raw *apiPlaylistResponse, callback MetadataCallback) PlaylistResponsePayload {
|
||||||
var info PlaylistInfoMetadata
|
var info PlaylistInfoMetadata
|
||||||
info.Tracks.Total = raw.Count
|
info.Tracks.Total = raw.Count
|
||||||
info.Followers.Total = raw.Followers
|
info.Followers.Total = raw.Followers
|
||||||
@@ -971,6 +1017,13 @@ func (c *SpotifyMetadataClient) formatPlaylistData(raw *apiPlaylistResponse) Pla
|
|||||||
info.Cover = raw.Cover
|
info.Cover = raw.Cover
|
||||||
info.Description = raw.Description
|
info.Description = raw.Description
|
||||||
|
|
||||||
|
if callback != nil {
|
||||||
|
callback(PlaylistResponsePayload{
|
||||||
|
PlaylistInfo: info,
|
||||||
|
TrackList: []AlbumTrackMetadata{},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
tracks := make([]AlbumTrackMetadata, 0, len(raw.Tracks))
|
tracks := make([]AlbumTrackMetadata, 0, len(raw.Tracks))
|
||||||
for _, item := range raw.Tracks {
|
for _, item := range raw.Tracks {
|
||||||
durationMS := parseDuration(item.Duration)
|
durationMS := parseDuration(item.Duration)
|
||||||
@@ -1015,13 +1068,17 @@ func (c *SpotifyMetadataClient) formatPlaylistData(raw *apiPlaylistResponse) Pla
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if callback != nil {
|
||||||
|
callback(tracks)
|
||||||
|
}
|
||||||
|
|
||||||
return PlaylistResponsePayload{
|
return PlaylistResponsePayload{
|
||||||
PlaylistInfo: info,
|
PlaylistInfo: info,
|
||||||
TrackList: tracks,
|
TrackList: tracks,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *SpotifyMetadataClient) formatArtistDiscographyData(ctx context.Context, raw *apiArtistResponse) (*ArtistDiscographyPayload, error) {
|
func (c *SpotifyMetadataClient) formatArtistDiscographyData(ctx context.Context, raw *apiArtistResponse, callback MetadataCallback) (*ArtistDiscographyPayload, error) {
|
||||||
discType := "all"
|
discType := "all"
|
||||||
|
|
||||||
info := ArtistInfoMetadata{
|
info := ArtistInfoMetadata{
|
||||||
@@ -1067,7 +1124,17 @@ func (c *SpotifyMetadataClient) formatArtistDiscographyData(ctx context.Context,
|
|||||||
Images: alb.Cover,
|
Images: alb.Cover,
|
||||||
ExternalURL: fmt.Sprintf("https://open.spotify.com/album/%s", alb.ID),
|
ExternalURL: fmt.Sprintf("https://open.spotify.com/album/%s", alb.ID),
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if callback != nil {
|
||||||
|
callback(ArtistDiscographyPayload{
|
||||||
|
ArtistInfo: info,
|
||||||
|
AlbumList: albumList,
|
||||||
|
TrackList: []AlbumTrackMetadata{},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, alb := range raw.Discography.All {
|
||||||
go func(albumID string, albumName string) {
|
go func(albumID string, albumName string) {
|
||||||
sem <- struct{}{}
|
sem <- struct{}{}
|
||||||
|
|
||||||
@@ -1081,7 +1148,7 @@ func (c *SpotifyMetadataClient) formatArtistDiscographyData(ctx context.Context,
|
|||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
|
|
||||||
albumData, err := c.fetchAlbumWithClient(ctx, sharedClient, albumID)
|
albumData, err := c.fetchAlbumWithClient(ctx, sharedClient, albumID, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Error getting tracks for album %s: %v\n", albumName, err)
|
fmt.Printf("Error getting tracks for album %s: %v\n", albumName, err)
|
||||||
resultsChan <- fetchResult{tracks: []AlbumTrackMetadata{}}
|
resultsChan <- fetchResult{tracks: []AlbumTrackMetadata{}}
|
||||||
@@ -1131,6 +1198,9 @@ func (c *SpotifyMetadataClient) formatArtistDiscographyData(ctx context.Context,
|
|||||||
IsExplicit: tr.IsExplicit,
|
IsExplicit: tr.IsExplicit,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
if callback != nil {
|
||||||
|
callback(tracks)
|
||||||
|
}
|
||||||
resultsChan <- fetchResult{tracks: tracks}
|
resultsChan <- fetchResult{tracks: tracks}
|
||||||
}(alb.ID, alb.Name)
|
}(alb.ID, alb.Name)
|
||||||
}
|
}
|
||||||
@@ -1290,7 +1360,7 @@ func (c *SpotifyMetadataClient) Search(ctx context.Context, query string, limit
|
|||||||
return nil, fmt.Errorf("failed to query search: %w", err)
|
return nil, fmt.Errorf("failed to query search: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
filteredData := FilterSearch(data)
|
filteredData := FilterSearch(data, c.Separator)
|
||||||
|
|
||||||
jsonData, err := json.Marshal(filteredData)
|
jsonData, err := json.Marshal(filteredData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1407,7 +1477,7 @@ func (c *SpotifyMetadataClient) SearchByType(ctx context.Context, query string,
|
|||||||
return nil, fmt.Errorf("failed to query search: %w", err)
|
return nil, fmt.Errorf("failed to query search: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
filteredData := FilterSearch(data)
|
filteredData := FilterSearch(data, c.Separator)
|
||||||
|
|
||||||
jsonData, err := json.Marshal(filteredData)
|
jsonData, err := json.Marshal(filteredData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+4
-35
@@ -8,7 +8,6 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -91,47 +90,17 @@ func (t *TidalDownloader) GetAvailableAPIs() ([]string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (t *TidalDownloader) GetTidalURLFromSpotify(spotifyTrackID string) (string, error) {
|
func (t *TidalDownloader) GetTidalURLFromSpotify(spotifyTrackID string) (string, error) {
|
||||||
|
|
||||||
spotifyBase := "https://open.spotify.com/track/"
|
|
||||||
spotifyURL := fmt.Sprintf("%s%s", spotifyBase, spotifyTrackID)
|
|
||||||
|
|
||||||
apiBase := "https://api.song.link/v1-alpha.1/links?url="
|
|
||||||
apiURL := fmt.Sprintf("%s%s", apiBase, 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", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36")
|
|
||||||
|
|
||||||
fmt.Println("Getting Tidal URL...")
|
fmt.Println("Getting Tidal URL...")
|
||||||
|
client := NewSongLinkClient()
|
||||||
resp, err := t.client.Do(req)
|
urls, err := client.GetAllURLsFromSpotify(spotifyTrackID, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to get Tidal URL: %w", err)
|
return "", fmt.Errorf("failed to get Tidal URL: %w", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
tidalURL := urls.TidalURL
|
||||||
return "", fmt.Errorf("API returned status %d", resp.StatusCode)
|
if tidalURL == "" {
|
||||||
}
|
|
||||||
|
|
||||||
var songLinkResp struct {
|
|
||||||
LinksByPlatform map[string]struct {
|
|
||||||
URL string `json:"url"`
|
|
||||||
} `json:"linksByPlatform"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&songLinkResp); err != nil {
|
|
||||||
return "", fmt.Errorf("failed to decode response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]
|
|
||||||
if !ok || tidalLink.URL == "" {
|
|
||||||
return "", fmt.Errorf("tidal link not found")
|
return "", fmt.Errorf("tidal link not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
tidalURL := tidalLink.URL
|
|
||||||
fmt.Printf("Found Tidal URL: %s\n", tidalURL)
|
fmt.Printf("Found Tidal URL: %s\n", tidalURL)
|
||||||
return tidalURL, nil
|
return tidalURL, nil
|
||||||
}
|
}
|
||||||
|
|||||||
+92
-17
@@ -20,6 +20,7 @@ import { DownloadQueue } from "@/components/DownloadQueue";
|
|||||||
import { DownloadProgressToast } from "@/components/DownloadProgressToast";
|
import { DownloadProgressToast } from "@/components/DownloadProgressToast";
|
||||||
import { AudioAnalysisPage } from "@/components/AudioAnalysisPage";
|
import { AudioAnalysisPage } from "@/components/AudioAnalysisPage";
|
||||||
import { AudioConverterPage } from "@/components/AudioConverterPage";
|
import { AudioConverterPage } from "@/components/AudioConverterPage";
|
||||||
|
import { AudioResamplerPage } from "@/components/AudioResamplerPage";
|
||||||
import { FileManagerPage } from "@/components/FileManagerPage";
|
import { FileManagerPage } from "@/components/FileManagerPage";
|
||||||
import { SettingsPage } from "@/components/SettingsPage";
|
import { SettingsPage } from "@/components/SettingsPage";
|
||||||
import { DebugLoggerPage } from "@/components/DebugLoggerPage";
|
import { DebugLoggerPage } from "@/components/DebugLoggerPage";
|
||||||
@@ -35,6 +36,72 @@ import { useDownloadQueueDialog } from "@/hooks/useDownloadQueueDialog";
|
|||||||
import { useDownloadProgress } from "@/hooks/useDownloadProgress";
|
import { useDownloadProgress } from "@/hooks/useDownloadProgress";
|
||||||
const HISTORY_KEY = "spotiflac_fetch_history";
|
const HISTORY_KEY = "spotiflac_fetch_history";
|
||||||
const MAX_HISTORY = 5;
|
const MAX_HISTORY = 5;
|
||||||
|
function extractSpotifyEntityFromURL(url: string): {
|
||||||
|
type: string;
|
||||||
|
id: string;
|
||||||
|
} | null {
|
||||||
|
const trimmed = url.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const spotifyUriMatch = trimmed.match(/^spotify:(track|album|playlist|artist):([A-Za-z0-9]+)$/i);
|
||||||
|
if (spotifyUriMatch) {
|
||||||
|
return {
|
||||||
|
type: spotifyUriMatch[1].toLowerCase(),
|
||||||
|
id: spotifyUriMatch[2],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = new URL(trimmed);
|
||||||
|
const segments = parsed.pathname.split("/").filter(Boolean);
|
||||||
|
const supportedTypes = new Set(["track", "album", "playlist", "artist"]);
|
||||||
|
for (let i = 0; i < segments.length - 1; i++) {
|
||||||
|
const segment = segments[i].toLowerCase();
|
||||||
|
if (!supportedTypes.has(segment)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const id = segments[i + 1];
|
||||||
|
if (id) {
|
||||||
|
return { type: segment, id };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
function normalizeHistoryURL(url: string): string {
|
||||||
|
const trimmed = url.trim();
|
||||||
|
if (!trimmed)
|
||||||
|
return trimmed;
|
||||||
|
const withoutQuery = trimmed.split("?")[0].replace(/\/+$/, "");
|
||||||
|
const spotifyEntity = extractSpotifyEntityFromURL(withoutQuery);
|
||||||
|
if (spotifyEntity) {
|
||||||
|
return `https://open.spotify.com/${spotifyEntity.type}/${spotifyEntity.id}`;
|
||||||
|
}
|
||||||
|
return withoutQuery.replace(/(\/artist\/[A-Za-z0-9]+)\/discography\/all$/i, "$1");
|
||||||
|
}
|
||||||
|
function getHistoryIdentityKey(type: HistoryItem["type"], url: string): string {
|
||||||
|
const normalizedUrl = normalizeHistoryURL(url);
|
||||||
|
const spotifyEntity = extractSpotifyEntityFromURL(normalizedUrl);
|
||||||
|
if (spotifyEntity) {
|
||||||
|
return `${type}:${spotifyEntity.id}`;
|
||||||
|
}
|
||||||
|
return `${type}:${normalizedUrl}`;
|
||||||
|
}
|
||||||
|
function dedupeHistoryItems(items: HistoryItem[]): HistoryItem[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const deduped: HistoryItem[] = [];
|
||||||
|
for (const item of items) {
|
||||||
|
const normalizedUrl = normalizeHistoryURL(item.url);
|
||||||
|
const key = getHistoryIdentityKey(item.type, normalizedUrl);
|
||||||
|
if (seen.has(key))
|
||||||
|
continue;
|
||||||
|
seen.add(key);
|
||||||
|
deduped.push({ ...item, url: normalizedUrl });
|
||||||
|
}
|
||||||
|
return deduped;
|
||||||
|
}
|
||||||
function App() {
|
function App() {
|
||||||
const [currentPage, setCurrentPage] = useState<PageType>("main");
|
const [currentPage, setCurrentPage] = useState<PageType>("main");
|
||||||
const [spotifyUrl, setSpotifyUrl] = useState("");
|
const [spotifyUrl, setSpotifyUrl] = useState("");
|
||||||
@@ -166,7 +233,9 @@ function App() {
|
|||||||
try {
|
try {
|
||||||
const saved = localStorage.getItem(HISTORY_KEY);
|
const saved = localStorage.getItem(HISTORY_KEY);
|
||||||
if (saved) {
|
if (saved) {
|
||||||
setFetchHistory(JSON.parse(saved));
|
const deduped = dedupeHistoryItems(JSON.parse(saved));
|
||||||
|
setFetchHistory(deduped);
|
||||||
|
localStorage.setItem(HISTORY_KEY, JSON.stringify(deduped));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
@@ -221,9 +290,12 @@ function App() {
|
|||||||
};
|
};
|
||||||
const addToHistory = (item: Omit<HistoryItem, "id" | "timestamp">) => {
|
const addToHistory = (item: Omit<HistoryItem, "id" | "timestamp">) => {
|
||||||
setFetchHistory((prev) => {
|
setFetchHistory((prev) => {
|
||||||
const filtered = prev.filter((h) => h.url !== item.url);
|
const normalizedUrl = normalizeHistoryURL(item.url);
|
||||||
|
const identityKey = getHistoryIdentityKey(item.type, normalizedUrl);
|
||||||
|
const filtered = prev.filter((h) => getHistoryIdentityKey(h.type, h.url) !== identityKey);
|
||||||
const newItem: HistoryItem = {
|
const newItem: HistoryItem = {
|
||||||
...item,
|
...item,
|
||||||
|
url: normalizedUrl,
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
};
|
};
|
||||||
@@ -344,6 +416,8 @@ function App() {
|
|||||||
if ("album_info" in metadata.metadata) {
|
if ("album_info" in metadata.metadata) {
|
||||||
const { album_info, track_list } = metadata.metadata;
|
const { album_info, track_list } = metadata.metadata;
|
||||||
return (<AlbumInfo albumInfo={album_info} trackList={track_list} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={download.downloadedTracks} failedTracks={download.failedTracks} skippedTracks={download.skippedTracks} downloadingTrack={download.downloadingTrack} isDownloading={download.isDownloading} bulkDownloadType={download.bulkDownloadType} downloadProgress={download.downloadProgress} currentDownloadInfo={download.currentDownloadInfo} currentPage={currentListPage} itemsPerPage={ITEMS_PER_PAGE} downloadedLyrics={lyrics.downloadedLyrics} failedLyrics={lyrics.failedLyrics} skippedLyrics={lyrics.skippedLyrics} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} checkingAvailabilityTrack={availability.checkingTrackId} availabilityMap={availability.availabilityMap} downloadedCovers={cover.downloadedCovers} failedCovers={cover.failedCovers} skippedCovers={cover.skippedCovers} downloadingCoverTrack={cover.downloadingCoverTrack} isBulkDownloadingCovers={cover.isBulkDownloadingCovers} isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics} onSearchChange={handleSearchChange} onSortChange={setSortBy} onToggleTrack={toggleTrackSelection} onToggleSelectAll={toggleSelectAll} onDownloadTrack={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, album_info.name, position, albumArtist, releaseDate, discNumber, true)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, album_info.name, position, trackId, albumArtist, releaseDate, discNumber, true)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, album_info.name, undefined, true)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, album_info.name, true)} onDownloadAll={() => download.handleDownloadAll(track_list, album_info.name, true)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, album_info.name, true)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onPageChange={setCurrentListPage} onBack={metadata.resetMetadata} onArtistClick={async (artist) => {
|
return (<AlbumInfo albumInfo={album_info} trackList={track_list} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={download.downloadedTracks} failedTracks={download.failedTracks} skippedTracks={download.skippedTracks} downloadingTrack={download.downloadingTrack} isDownloading={download.isDownloading} bulkDownloadType={download.bulkDownloadType} downloadProgress={download.downloadProgress} currentDownloadInfo={download.currentDownloadInfo} currentPage={currentListPage} itemsPerPage={ITEMS_PER_PAGE} downloadedLyrics={lyrics.downloadedLyrics} failedLyrics={lyrics.failedLyrics} skippedLyrics={lyrics.skippedLyrics} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} checkingAvailabilityTrack={availability.checkingTrackId} availabilityMap={availability.availabilityMap} downloadedCovers={cover.downloadedCovers} failedCovers={cover.failedCovers} skippedCovers={cover.skippedCovers} downloadingCoverTrack={cover.downloadingCoverTrack} isBulkDownloadingCovers={cover.isBulkDownloadingCovers} isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics} onSearchChange={handleSearchChange} onSortChange={setSortBy} onToggleTrack={toggleTrackSelection} onToggleSelectAll={toggleSelectAll} onDownloadTrack={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, album_info.name, position, albumArtist, releaseDate, discNumber, true)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, album_info.name, position, trackId, albumArtist, releaseDate, discNumber, true)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, album_info.name, undefined, true)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, album_info.name, true)} onDownloadAll={() => download.handleDownloadAll(track_list, album_info.name, true)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, album_info.name, true)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onPageChange={setCurrentListPage} onBack={metadata.resetMetadata} onArtistClick={async (artist) => {
|
||||||
|
const pendingArtistUrl = artist.external_urls.replace(/\/$/, "") + "/discography/all";
|
||||||
|
setSpotifyUrl(pendingArtistUrl);
|
||||||
const artistUrl = await metadata.handleArtistClick(artist);
|
const artistUrl = await metadata.handleArtistClick(artist);
|
||||||
if (artistUrl) {
|
if (artistUrl) {
|
||||||
setSpotifyUrl(artistUrl);
|
setSpotifyUrl(artistUrl);
|
||||||
@@ -358,6 +432,8 @@ function App() {
|
|||||||
if ("playlist_info" in metadata.metadata) {
|
if ("playlist_info" in metadata.metadata) {
|
||||||
const { playlist_info, track_list } = metadata.metadata;
|
const { playlist_info, track_list } = metadata.metadata;
|
||||||
return (<PlaylistInfo playlistInfo={playlist_info} trackList={track_list} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={download.downloadedTracks} failedTracks={download.failedTracks} skippedTracks={download.skippedTracks} downloadingTrack={download.downloadingTrack} isDownloading={download.isDownloading} bulkDownloadType={download.bulkDownloadType} downloadProgress={download.downloadProgress} currentDownloadInfo={download.currentDownloadInfo} currentPage={currentListPage} itemsPerPage={ITEMS_PER_PAGE} downloadedLyrics={lyrics.downloadedLyrics} failedLyrics={lyrics.failedLyrics} skippedLyrics={lyrics.skippedLyrics} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} checkingAvailabilityTrack={availability.checkingTrackId} availabilityMap={availability.availabilityMap} downloadedCovers={cover.downloadedCovers} failedCovers={cover.failedCovers} skippedCovers={cover.skippedCovers} downloadingCoverTrack={cover.downloadingCoverTrack} isBulkDownloadingCovers={cover.isBulkDownloadingCovers} isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics} onSearchChange={handleSearchChange} onSortChange={setSortBy} onToggleTrack={toggleTrackSelection} onToggleSelectAll={toggleSelectAll} onDownloadTrack={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, playlist_info.owner.name, position, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, playlist_info.owner.name, position, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, playlist_info.owner.name)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, playlist_info.owner.name)} onDownloadAll={() => download.handleDownloadAll(track_list, playlist_info.owner.name)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, playlist_info.owner.name)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onPageChange={setCurrentListPage} onBack={metadata.resetMetadata} onAlbumClick={metadata.handleAlbumClick} onArtistClick={async (artist) => {
|
return (<PlaylistInfo playlistInfo={playlist_info} trackList={track_list} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={download.downloadedTracks} failedTracks={download.failedTracks} skippedTracks={download.skippedTracks} downloadingTrack={download.downloadingTrack} isDownloading={download.isDownloading} bulkDownloadType={download.bulkDownloadType} downloadProgress={download.downloadProgress} currentDownloadInfo={download.currentDownloadInfo} currentPage={currentListPage} itemsPerPage={ITEMS_PER_PAGE} downloadedLyrics={lyrics.downloadedLyrics} failedLyrics={lyrics.failedLyrics} skippedLyrics={lyrics.skippedLyrics} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} checkingAvailabilityTrack={availability.checkingTrackId} availabilityMap={availability.availabilityMap} downloadedCovers={cover.downloadedCovers} failedCovers={cover.failedCovers} skippedCovers={cover.skippedCovers} downloadingCoverTrack={cover.downloadingCoverTrack} isBulkDownloadingCovers={cover.isBulkDownloadingCovers} isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics} onSearchChange={handleSearchChange} onSortChange={setSortBy} onToggleTrack={toggleTrackSelection} onToggleSelectAll={toggleSelectAll} onDownloadTrack={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, playlist_info.owner.name, position, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, playlist_info.owner.name, position, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, playlist_info.owner.name)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, playlist_info.owner.name)} onDownloadAll={() => download.handleDownloadAll(track_list, playlist_info.owner.name)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, playlist_info.owner.name)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onPageChange={setCurrentListPage} onBack={metadata.resetMetadata} onAlbumClick={metadata.handleAlbumClick} onArtistClick={async (artist) => {
|
||||||
|
const pendingArtistUrl = artist.external_urls.replace(/\/$/, "") + "/discography/all";
|
||||||
|
setSpotifyUrl(pendingArtistUrl);
|
||||||
const artistUrl = await metadata.handleArtistClick(artist);
|
const artistUrl = await metadata.handleArtistClick(artist);
|
||||||
if (artistUrl) {
|
if (artistUrl) {
|
||||||
setSpotifyUrl(artistUrl);
|
setSpotifyUrl(artistUrl);
|
||||||
@@ -372,6 +448,8 @@ function App() {
|
|||||||
if ("artist_info" in metadata.metadata) {
|
if ("artist_info" in metadata.metadata) {
|
||||||
const { artist_info, album_list, track_list } = metadata.metadata;
|
const { artist_info, album_list, track_list } = metadata.metadata;
|
||||||
return (<ArtistInfo artistInfo={artist_info} albumList={album_list} trackList={track_list} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={download.downloadedTracks} failedTracks={download.failedTracks} skippedTracks={download.skippedTracks} downloadingTrack={download.downloadingTrack} isDownloading={download.isDownloading} bulkDownloadType={download.bulkDownloadType} downloadProgress={download.downloadProgress} currentDownloadInfo={download.currentDownloadInfo} currentPage={currentListPage} itemsPerPage={ITEMS_PER_PAGE} downloadedLyrics={lyrics.downloadedLyrics} failedLyrics={lyrics.failedLyrics} skippedLyrics={lyrics.skippedLyrics} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} checkingAvailabilityTrack={availability.checkingTrackId} availabilityMap={availability.availabilityMap} downloadedCovers={cover.downloadedCovers} failedCovers={cover.failedCovers} skippedCovers={cover.skippedCovers} downloadingCoverTrack={cover.downloadingCoverTrack} isBulkDownloadingCovers={cover.isBulkDownloadingCovers} isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics} onSearchChange={handleSearchChange} onSortChange={setSortBy} onToggleTrack={toggleTrackSelection} onToggleSelectAll={toggleSelectAll} onDownloadTrack={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, artist_info.name, position, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, artist_info.name, position, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, artist_info.name)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, artist_info.name)} onDownloadAll={() => download.handleDownloadAll(track_list, artist_info.name)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, artist_info.name)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onAlbumClick={metadata.handleAlbumClick} onBack={metadata.resetMetadata} onArtistClick={async (artist) => {
|
return (<ArtistInfo artistInfo={artist_info} albumList={album_list} trackList={track_list} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={download.downloadedTracks} failedTracks={download.failedTracks} skippedTracks={download.skippedTracks} downloadingTrack={download.downloadingTrack} isDownloading={download.isDownloading} bulkDownloadType={download.bulkDownloadType} downloadProgress={download.downloadProgress} currentDownloadInfo={download.currentDownloadInfo} currentPage={currentListPage} itemsPerPage={ITEMS_PER_PAGE} downloadedLyrics={lyrics.downloadedLyrics} failedLyrics={lyrics.failedLyrics} skippedLyrics={lyrics.skippedLyrics} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} checkingAvailabilityTrack={availability.checkingTrackId} availabilityMap={availability.availabilityMap} downloadedCovers={cover.downloadedCovers} failedCovers={cover.failedCovers} skippedCovers={cover.skippedCovers} downloadingCoverTrack={cover.downloadingCoverTrack} isBulkDownloadingCovers={cover.isBulkDownloadingCovers} isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics} onSearchChange={handleSearchChange} onSortChange={setSortBy} onToggleTrack={toggleTrackSelection} onToggleSelectAll={toggleSelectAll} onDownloadTrack={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, artist_info.name, position, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, artist_info.name, position, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, artist_info.name)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, artist_info.name)} onDownloadAll={() => download.handleDownloadAll(track_list, artist_info.name)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, artist_info.name)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onAlbumClick={metadata.handleAlbumClick} onBack={metadata.resetMetadata} onArtistClick={async (artist) => {
|
||||||
|
const pendingArtistUrl = artist.external_urls.replace(/\/$/, "") + "/discography/all";
|
||||||
|
setSpotifyUrl(pendingArtistUrl);
|
||||||
const artistUrl = await metadata.handleArtistClick(artist);
|
const artistUrl = await metadata.handleArtistClick(artist);
|
||||||
if (artistUrl) {
|
if (artistUrl) {
|
||||||
setSpotifyUrl(artistUrl);
|
setSpotifyUrl(artistUrl);
|
||||||
@@ -428,6 +506,8 @@ function App() {
|
|||||||
return <AudioAnalysisPage />;
|
return <AudioAnalysisPage />;
|
||||||
case "audio-converter":
|
case "audio-converter":
|
||||||
return <AudioConverterPage />;
|
return <AudioConverterPage />;
|
||||||
|
case "audio-resampler":
|
||||||
|
return <AudioResamplerPage />;
|
||||||
case "file-manager":
|
case "file-manager":
|
||||||
return <FileManagerPage />;
|
return <FileManagerPage />;
|
||||||
default:
|
default:
|
||||||
@@ -456,6 +536,10 @@ function App() {
|
|||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={async () => {
|
<Button onClick={async () => {
|
||||||
|
const pendingAlbumUrl = metadata.selectedAlbum?.external_urls;
|
||||||
|
if (pendingAlbumUrl) {
|
||||||
|
setSpotifyUrl(pendingAlbumUrl);
|
||||||
|
}
|
||||||
const albumUrl = await metadata.handleConfirmAlbumFetch();
|
const albumUrl = await metadata.handleConfirmAlbumFetch();
|
||||||
if (albumUrl) {
|
if (albumUrl) {
|
||||||
setSpotifyUrl(albumUrl);
|
setSpotifyUrl(albumUrl);
|
||||||
@@ -531,17 +615,13 @@ function App() {
|
|||||||
FFmpeg Required
|
FFmpeg Required
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription className="text-sm text-foreground/70 leading-relaxed font-normal">
|
<DialogDescription className="text-sm text-foreground/70 leading-relaxed font-normal">
|
||||||
{brewPath ? (
|
{brewPath ? (<>
|
||||||
<>
|
|
||||||
FFmpeg is essential for SpotiFLAC to function properly.
|
FFmpeg is essential for SpotiFLAC to function properly.
|
||||||
Homebrew detected. Recommended: <span className="text-foreground font-semibold">brew install ffmpeg</span>
|
Homebrew detected. Recommended: <span className="text-foreground font-semibold">brew install ffmpeg</span>
|
||||||
</>
|
</>) : (<>
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
FFmpeg is essential for SpotiFLAC to function properly.
|
FFmpeg is essential for SpotiFLAC to function properly.
|
||||||
This setup will download about <span className="text-foreground font-semibold">100-200MB</span> of data.
|
This setup will download about <span className="text-foreground font-semibold">100-200MB</span> of data.
|
||||||
</>
|
</>)}
|
||||||
)}
|
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@@ -573,16 +653,11 @@ function App() {
|
|||||||
{!isInstallingFFmpeg && (<Button variant="outline" className="flex-1 h-11 text-sm font-bold transition-colors" onClick={() => Quit()}>
|
{!isInstallingFFmpeg && (<Button variant="outline" className="flex-1 h-11 text-sm font-bold transition-colors" onClick={() => Quit()}>
|
||||||
Exit
|
Exit
|
||||||
</Button>)}
|
</Button>)}
|
||||||
{brewPath ? (
|
{brewPath ? (<Button className="flex-1 h-11 text-sm font-bold shadow-lg shadow-primary/10" onClick={() => handleInstallFFmpeg(true)} disabled={isInstallingFFmpeg}>
|
||||||
<Button className="flex-1 h-11 text-sm font-bold shadow-lg shadow-primary/10" onClick={() => handleInstallFFmpeg(true)} disabled={isInstallingFFmpeg}>
|
|
||||||
{isInstallingFFmpeg ? "Installing..." : "Install via Homebrew"}
|
{isInstallingFFmpeg ? "Installing..." : "Install via Homebrew"}
|
||||||
</Button>
|
</Button>) : (<Button className={`${isInstallingFFmpeg ? 'w-full' : 'flex-1'} h-11 text-sm font-bold shadow-lg shadow-primary/10`} onClick={() => handleInstallFFmpeg(false)} disabled={isInstallingFFmpeg}>
|
||||||
|
|
||||||
) : (
|
|
||||||
<Button className={`${isInstallingFFmpeg ? 'w-full' : 'flex-1'} h-11 text-sm font-bold shadow-lg shadow-primary/10`} onClick={() => handleInstallFFmpeg(false)} disabled={isInstallingFFmpeg}>
|
|
||||||
{isInstallingFFmpeg ? "Installing..." : "Install now"}
|
{isInstallingFFmpeg ? "Installing..." : "Install now"}
|
||||||
</Button>
|
</Button>)}
|
||||||
)}
|
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
@@ -2,9 +2,10 @@ import { useState, useEffect } from "react";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { openExternal } from "@/lib/utils";
|
import { openExternal } from "@/lib/utils";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@/components/ui/card";
|
||||||
import { Star, GitFork, Clock, Download, Blocks, Heart, Copy, CircleCheck } from "lucide-react";
|
import { Star, GitFork, Clock, Download, Blocks, Heart, Copy, CircleCheck, Info } from "lucide-react";
|
||||||
import AudioTTSProIcon from "@/assets/audiotts-pro.webp";
|
import AudioTTSProIcon from "@/assets/audiotts-pro.webp";
|
||||||
import ChatGPTTTSIcon from "@/assets/chatgpt-tts.webp";
|
import ChatGPTTTSIcon from "@/assets/chatgpt-tts.webp";
|
||||||
|
import XIcon from "@/assets/x.webp";
|
||||||
import XProIcon from "@/assets/x-pro.webp";
|
import XProIcon from "@/assets/x-pro.webp";
|
||||||
import SpotubeDLIcon from "@/assets/icons/spotubedl.svg";
|
import SpotubeDLIcon from "@/assets/icons/spotubedl.svg";
|
||||||
import SpotiDownloaderIcon from "@/assets/icons/spotidownloader.svg";
|
import SpotiDownloaderIcon from "@/assets/icons/spotidownloader.svg";
|
||||||
@@ -20,7 +21,7 @@ export function AboutPage() {
|
|||||||
const [copiedUsdt, setCopiedUsdt] = useState(false);
|
const [copiedUsdt, setCopiedUsdt] = useState(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchRepoStats = async () => {
|
const fetchRepoStats = async () => {
|
||||||
const CACHE_KEY = "github_repo_stats";
|
const CACHE_KEY = "github_repo_stats_v3";
|
||||||
const CACHE_DURATION = 1000 * 60 * 60;
|
const CACHE_DURATION = 1000 * 60 * 60;
|
||||||
const cached = localStorage.getItem(CACHE_KEY);
|
const cached = localStorage.getItem(CACHE_KEY);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
@@ -55,10 +56,10 @@ export function AboutPage() {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (repoRes.ok && releasesRes.ok && langsRes.ok) {
|
if (repoRes.ok) {
|
||||||
const repoData = await repoRes.json();
|
const repoData = await repoRes.json();
|
||||||
const releases = await releasesRes.json();
|
const releases = releasesRes.ok ? await releasesRes.json() : [];
|
||||||
const languages = await langsRes.json();
|
const languages = langsRes.ok ? await langsRes.json() : {};
|
||||||
let totalDownloads = 0;
|
let totalDownloads = 0;
|
||||||
let latestDownloads = 0;
|
let latestDownloads = 0;
|
||||||
let latestVersion = "";
|
let latestVersion = "";
|
||||||
@@ -79,6 +80,7 @@ export function AboutPage() {
|
|||||||
stars: repoData.stargazers_count,
|
stars: repoData.stargazers_count,
|
||||||
forks: repoData.forks_count,
|
forks: repoData.forks_count,
|
||||||
createdAt: repoData.created_at,
|
createdAt: repoData.created_at,
|
||||||
|
description: repoData.description,
|
||||||
totalDownloads,
|
totalDownloads,
|
||||||
latestDownloads,
|
latestDownloads,
|
||||||
latestVersion,
|
latestVersion,
|
||||||
@@ -128,6 +130,9 @@ export function AboutPage() {
|
|||||||
const getLangColor = (lang: string): string => {
|
const getLangColor = (lang: string): string => {
|
||||||
return langColors[lang] || "#858585";
|
return langColors[lang] || "#858585";
|
||||||
};
|
};
|
||||||
|
const getRepoDescription = (repoName: string): string => {
|
||||||
|
return repoStats[repoName]?.description || "";
|
||||||
|
};
|
||||||
return (<div className="flex flex-col space-y-4">
|
return (<div className="flex flex-col space-y-4">
|
||||||
<div className="flex items-center justify-between shrink-0">
|
<div className="flex items-center justify-between shrink-0">
|
||||||
<h2 className="text-2xl font-bold tracking-tight">About</h2>
|
<h2 className="text-2xl font-bold tracking-tight">About</h2>
|
||||||
@@ -150,12 +155,13 @@ export function AboutPage() {
|
|||||||
{activeTab === "projects" && (<div className="p-1 pr-2">
|
{activeTab === "projects" && (<div className="p-1 pr-2">
|
||||||
<div className="grid gap-2 grid-cols-4">
|
<div className="grid gap-2 grid-cols-4">
|
||||||
<div className="flex flex-col gap-2 h-full">
|
<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.cc/")}>
|
<Card className="hover:bg-muted/50 hover:border-primary/50 transition-colors cursor-pointer flex-1" onClick={() => openExternal("https://exyezed.qzz.io/")}>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Browser Extensions & Scripts</CardTitle>
|
<CardTitle>Browser Extensions & Scripts</CardTitle>
|
||||||
<CardDescription className="flex gap-3 pt-2">
|
<CardDescription className="flex gap-3 pt-2">
|
||||||
<img src={AudioTTSProIcon} className="h-8 w-8 rounded-md shadow-sm" alt="AudioTTS Pro"/>
|
<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={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"/>
|
<img src={XProIcon} className="h-8 w-8 rounded-md shadow-sm" alt="X Pro"/>
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -185,7 +191,7 @@ export function AboutPage() {
|
|||||||
SpotiDownloader
|
SpotiDownloader
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Get Spotify tracks in MP3 and FLAC via spotidownloader.com
|
{getRepoDescription("SpotiDownloader")}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
{repoStats["SpotiDownloader"] && (<CardContent className="space-y-3">
|
{repoStats["SpotiDownloader"] && (<CardContent className="space-y-3">
|
||||||
@@ -223,7 +229,7 @@ export function AboutPage() {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>)}
|
</CardContent>)}
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="hover:bg-muted/50 hover:border-primary/50 transition-colors cursor-pointer" onClick={() => openExternal("https://github.com/spotiverse/SpotiFLAC-Next")}>
|
<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>
|
<CardHeader>
|
||||||
<div className="flex justify-between items-start mb-2">
|
<div className="flex justify-between items-start mb-2">
|
||||||
<img src={SpotiFLACNextIcon} className="h-6 w-6 shrink-0" alt="SpotiFLAC Next"/>
|
<img src={SpotiFLACNextIcon} className="h-6 w-6 shrink-0" alt="SpotiFLAC Next"/>
|
||||||
@@ -235,18 +241,18 @@ export function AboutPage() {
|
|||||||
SpotiFLAC Next
|
SpotiFLAC Next
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account required.
|
{getRepoDescription("SpotiFLAC-Next")}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
{repoStats["SpotiFLAC-Next"] && (<CardContent className="space-y-3">
|
{repoStats["SpotiFLAC-Next"] && (<CardContent className="space-y-2">
|
||||||
<div className="flex flex-wrap gap-2 text-xs">
|
{repoStats["SpotiFLAC-Next"].languages?.length > 0 && (<div className="flex flex-wrap gap-2 text-xs">
|
||||||
{repoStats["SpotiFLAC-Next"].languages?.map((lang: string) => (<span key={lang} className="px-2 py-0.5 rounded-full font-medium" style={{
|
{repoStats["SpotiFLAC-Next"].languages.map((lang: string) => (<span key={lang} className="px-2 py-0.5 rounded-full font-medium" style={{
|
||||||
backgroundColor: getLangColor(lang) + "20",
|
backgroundColor: getLangColor(lang) + "20",
|
||||||
color: getLangColor(lang),
|
color: getLangColor(lang),
|
||||||
}}>
|
}}>
|
||||||
{lang}
|
{lang}
|
||||||
</span>))}
|
</span>))}
|
||||||
</div>
|
</div>)}
|
||||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<Star className="h-3.5 w-3.5 fill-amber-500 text-amber-500"/>{" "}
|
<Star className="h-3.5 w-3.5 fill-amber-500 text-amber-500"/>{" "}
|
||||||
@@ -261,15 +267,15 @@ Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account
|
|||||||
{formatTimeAgo(repoStats["SpotiFLAC-Next"].createdAt)}
|
{formatTimeAgo(repoStats["SpotiFLAC-Next"].createdAt)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1 text-xs text-muted-foreground items-start">
|
<div className="rounded-md border border-sky-500/25 bg-sky-500/8 px-3 py-2">
|
||||||
<span className="flex items-center gap-1">
|
<div className="mb-1 flex items-center gap-1.5 text-xs font-semibold text-sky-700 dark:text-sky-300">
|
||||||
<Download className="h-3.5 w-3.5"/> TOTAL:{" "}
|
<Info className="h-3.5 w-3.5"/>
|
||||||
{formatNumber(repoStats["SpotiFLAC-Next"].totalDownloads)}
|
Note
|
||||||
</span>
|
</div>
|
||||||
<span className="flex items-center gap-1 text-green-600 dark:text-green-400">
|
<p className="text-xs leading-relaxed text-sky-700 dark:text-sky-300">
|
||||||
<Download className="h-3.5 w-3.5"/> LATEST:{" "}
|
SpotiFLAC Next is a separate project created as a thank-you
|
||||||
{formatNumber(repoStats["SpotiFLAC-Next"].latestDownloads)}
|
to everyone who has supported SpotiFLAC on Ko-fi.
|
||||||
</span>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>)}
|
</CardContent>)}
|
||||||
</Card>
|
</Card>
|
||||||
@@ -285,8 +291,7 @@ Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account
|
|||||||
Twitter/X Media Batch Downloader
|
Twitter/X Media Batch Downloader
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
A GUI tool to download original-quality images and videos
|
{getRepoDescription("Twitter-X-Media-Batch-Downloader")}
|
||||||
from Twitter/X accounts, powered by gallery-dl by @mikf
|
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
{repoStats["Twitter-X-Media-Batch-Downloader"] && (<CardContent className="space-y-3">
|
{repoStats["Twitter-X-Media-Batch-Downloader"] && (<CardContent className="space-y-3">
|
||||||
@@ -345,7 +350,7 @@ Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account
|
|||||||
</div>
|
</div>
|
||||||
<Button className="h-10 w-full text-sm font-semibold text-white gap-2 group bg-[#72a4f2] hover:bg-[#5f8cd6]" onClick={() => openExternal("https://ko-fi.com/afkarxyz")}>
|
<Button className="h-10 w-full text-sm font-semibold text-white gap-2 group bg-[#72a4f2] hover:bg-[#5f8cd6]" onClick={() => openExternal("https://ko-fi.com/afkarxyz")}>
|
||||||
<img src={KofiSvg} className="w-5 h-5 shrink-0" alt="" aria-hidden="true"/>
|
<img src={KofiSvg} className="w-5 h-5 shrink-0" alt="" aria-hidden="true"/>
|
||||||
Support on Ko-fi
|
Support me on Ko-fi
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ export function AlbumInfo({ albumInfo, trackList, searchQuery, sortBy, selectedT
|
|||||||
if (response.already_exists)
|
if (response.already_exists)
|
||||||
toast.info("Cover already exists");
|
toast.info("Cover already exists");
|
||||||
else
|
else
|
||||||
toast.success("Album cover downloaded");
|
toast.success("Separate album cover downloaded");
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
toast.error(response.error || "Failed to download cover");
|
toast.error(response.error || "Failed to download cover");
|
||||||
@@ -153,7 +153,7 @@ export function AlbumInfo({ albumInfo, trackList, searchQuery, sortBy, selectedT
|
|||||||
{downloadingAlbumCover ? <Spinner /> : <ImageDown className="h-4 w-4"/>}
|
{downloadingAlbumCover ? <Spinner /> : <ImageDown className="h-4 w-4"/>}
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent><p>Download Album Cover</p></TooltipContent>
|
<TooltipContent><p>Download Separate Album Cover</p></TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>)}
|
</div>)}
|
||||||
@@ -203,7 +203,7 @@ export function AlbumInfo({ albumInfo, trackList, searchQuery, sortBy, selectedT
|
|||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>Download All Covers</p>
|
<p>Download All Separate Covers</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>)}
|
</Tooltip>)}
|
||||||
{downloadedTracks.size > 0 && (<Button onClick={onOpenFolder} variant="outline">
|
{downloadedTracks.size > 0 && (<Button onClick={onOpenFolder} variant="outline">
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ const SOURCES: ApiSource[] = [
|
|||||||
{ id: "tidal7", type: "tidal", name: "Tidal G", url: "https://tidal.kinoplus.online" },
|
{ 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: "qobuz1", type: "qobuz", name: "Qobuz A", url: "https://dab.yeet.su" },
|
||||||
{ id: "qobuz2", type: "qobuz", name: "Qobuz B", url: "https://dabmusic.xyz" },
|
{ id: "qobuz2", type: "qobuz", name: "Qobuz B", url: "https://dabmusic.xyz" },
|
||||||
{ id: "qobuz3", type: "qbz", name: "Qobuz C", url: "https://qbz.afkarxyz.fun" },
|
{ id: "qobuz3", type: "qbz", name: "Qobuz C", url: "https://qbz.afkarxyz.qzz.io" },
|
||||||
{ id: "amazon1", type: "amazon", name: "Amazon Music", url: "https://amzn.afkarxyz.fun" },
|
{ id: "amazon1", type: "amazon", name: "Amazon Music", url: "https://amzn.afkarxyz.qzz.io" },
|
||||||
];
|
];
|
||||||
export function ApiStatusTab() {
|
export function ApiStatusTab() {
|
||||||
const [statuses, setStatuses] = useState<Record<string, "checking" | "online" | "offline" | "idle">>({});
|
const [statuses, setStatuses] = useState<Record<string, "checking" | "online" | "offline" | "idle">>({});
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ interface ArtistInfoProps {
|
|||||||
header?: string;
|
header?: string;
|
||||||
gallery?: string[];
|
gallery?: string[];
|
||||||
followers: number;
|
followers: number;
|
||||||
|
total_albums?: number;
|
||||||
genres: string[];
|
genres: string[];
|
||||||
biography?: string;
|
biography?: string;
|
||||||
verified?: boolean;
|
verified?: boolean;
|
||||||
@@ -99,6 +100,31 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
|
|||||||
const [downloadingGalleryIndex, setDownloadingGalleryIndex] = useState<number | null>(null);
|
const [downloadingGalleryIndex, setDownloadingGalleryIndex] = useState<number | null>(null);
|
||||||
const [downloadingAllGallery, setDownloadingAllGallery] = useState(false);
|
const [downloadingAllGallery, setDownloadingAllGallery] = useState(false);
|
||||||
const [activeTab, setActiveTab] = useState<"albums" | "tracks" | "gallery">("albums");
|
const [activeTab, setActiveTab] = useState<"albums" | "tracks" | "gallery">("albums");
|
||||||
|
const [activeAlbumFilter, setActiveAlbumFilter] = useState<string>("all");
|
||||||
|
const displayedAlbumCount = artistInfo.total_albums || albumList.length;
|
||||||
|
const albumFilterCounts = useMemo(() => {
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
counts.set("all", (albumList || []).length);
|
||||||
|
for (const album of albumList || []) {
|
||||||
|
const type = (album.album_type || "").trim().toLowerCase();
|
||||||
|
if (!type)
|
||||||
|
continue;
|
||||||
|
counts.set(type, (counts.get(type) || 0) + 1);
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
}, [albumList]);
|
||||||
|
const albumFilters = useMemo(() => {
|
||||||
|
const uniqueTypes = Array.from(new Set((albumList || [])
|
||||||
|
.map((album) => (album.album_type || "").trim().toLowerCase())
|
||||||
|
.filter(Boolean)));
|
||||||
|
return ["all", ...uniqueTypes];
|
||||||
|
}, [albumList]);
|
||||||
|
const filteredAlbums = useMemo(() => {
|
||||||
|
if (activeAlbumFilter === "all") {
|
||||||
|
return albumList || [];
|
||||||
|
}
|
||||||
|
return (albumList || []).filter((album) => (album.album_type || "").trim().toLowerCase() === activeAlbumFilter);
|
||||||
|
}, [albumList, activeAlbumFilter]);
|
||||||
const filteredAlbumGroups = useMemo(() => {
|
const filteredAlbumGroups = useMemo(() => {
|
||||||
const albumTypeMap = new Map(albumList.map(a => [a.name, a.album_type]));
|
const albumTypeMap = new Map(albumList.map(a => [a.name, a.album_type]));
|
||||||
const albumGroups = trackList.reduce((acc, track) => {
|
const albumGroups = trackList.reduce((acc, track) => {
|
||||||
@@ -125,6 +151,17 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
|
|||||||
return dateB.localeCompare(dateA);
|
return dateB.localeCompare(dateA);
|
||||||
});
|
});
|
||||||
}, [trackList, albumList]);
|
}, [trackList, albumList]);
|
||||||
|
const formatAlbumFilterLabel = (value: string) => {
|
||||||
|
const count = albumFilterCounts.get(value) || 0;
|
||||||
|
if (value === "all")
|
||||||
|
return `All (${count})`;
|
||||||
|
const label = value
|
||||||
|
.split(/[_\s]+/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||||
|
.join(" ");
|
||||||
|
return `${label} (${count})`;
|
||||||
|
};
|
||||||
const handleDownloadHeader = async () => {
|
const handleDownloadHeader = async () => {
|
||||||
if (!artistInfo.header)
|
if (!artistInfo.header)
|
||||||
return;
|
return;
|
||||||
@@ -330,9 +367,9 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
|
|||||||
</>)}
|
</>)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-sm flex-wrap text-white/90">
|
<div className="flex items-center gap-2 text-sm flex-wrap text-white/90">
|
||||||
<span>{albumList.length} {albumList.length === 1 ? "album" : "albums"}</span>
|
<span>{displayedAlbumCount.toLocaleString()} {displayedAlbumCount === 1 ? "album" : "albums"}</span>
|
||||||
<span>•</span>
|
<span>•</span>
|
||||||
<span>{trackList.length} {trackList.length === 1 ? "track" : "tracks"}</span>
|
<span>{trackList.length.toLocaleString()} {trackList.length === 1 ? "track" : "tracks"}</span>
|
||||||
{artistInfo.genres.length > 0 && (<>
|
{artistInfo.genres.length > 0 && (<>
|
||||||
<span>•</span>
|
<span>•</span>
|
||||||
<span>{artistInfo.genres.join(", ")}</span>
|
<span>{artistInfo.genres.join(", ")}</span>
|
||||||
@@ -383,9 +420,9 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
|
|||||||
</>)}
|
</>)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-sm flex-wrap">
|
<div className="flex items-center gap-2 text-sm flex-wrap">
|
||||||
<span>{albumList.length} {albumList.length === 1 ? "album" : "albums"}</span>
|
<span>{displayedAlbumCount.toLocaleString()} {displayedAlbumCount === 1 ? "album" : "albums"}</span>
|
||||||
<span>•</span>
|
<span>•</span>
|
||||||
<span>{trackList.length} {trackList.length === 1 ? "track" : "tracks"}</span>
|
<span>{trackList.length.toLocaleString()} {trackList.length === 1 ? "track" : "tracks"}</span>
|
||||||
{artistInfo.genres.length > 0 && (<>
|
{artistInfo.genres.length > 0 && (<>
|
||||||
<span>•</span>
|
<span>•</span>
|
||||||
<span>{artistInfo.genres.join(", ")}</span>
|
<span>{artistInfo.genres.join(", ")}</span>
|
||||||
@@ -412,7 +449,7 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
|
|||||||
|
|
||||||
{activeTab === "gallery" && hasGallery && (<div className="space-y-4">
|
{activeTab === "gallery" && hasGallery && (<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-2xl font-bold">Gallery ({artistInfo.gallery!.length})</h3>
|
<h3 className="text-2xl font-bold">Gallery ({artistInfo.gallery!.length.toLocaleString()})</h3>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button onClick={handleDownloadAllGallery} size="sm" variant="outline" disabled={downloadingAllGallery}>
|
<Button onClick={handleDownloadAllGallery} size="sm" variant="outline" disabled={downloadingAllGallery}>
|
||||||
@@ -459,8 +496,13 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
|
|||||||
</Button>)}
|
</Button>)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{albumFilters.length > 1 && (<div className="flex flex-wrap gap-2">
|
||||||
|
{albumFilters.map((filter) => (<Button key={filter} size="sm" variant={activeAlbumFilter === filter ? "default" : "outline"} onClick={() => setActiveAlbumFilter(filter)}>
|
||||||
|
{formatAlbumFilterLabel(filter)}
|
||||||
|
</Button>))}
|
||||||
|
</div>)}
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||||
{albumList.map((album) => {
|
{filteredAlbums.map((album) => {
|
||||||
const albumTracks = trackList.filter(t => t.album_name === album.name);
|
const albumTracks = trackList.filter(t => t.album_name === album.name);
|
||||||
const tracksWithId = albumTracks.filter(t => t.spotify_id);
|
const tracksWithId = albumTracks.filter(t => t.spotify_id);
|
||||||
const isSelected = tracksWithId.length > 0 && tracksWithId.every(t => selectedTracks.includes(t.spotify_id!));
|
const isSelected = tracksWithId.length > 0 && tracksWithId.every(t => selectedTracks.includes(t.spotify_id!));
|
||||||
@@ -493,6 +535,9 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
|
|||||||
</div>);
|
</div>);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
{filteredAlbums.length === 0 && (<div className="rounded-lg border border-dashed border-border p-6 text-sm text-muted-foreground">
|
||||||
|
No releases found for the selected discography filter.
|
||||||
|
</div>)}
|
||||||
</div>)}
|
</div>)}
|
||||||
|
|
||||||
{activeTab === "tracks" && trackList.length > 0 && (<div className="space-y-4">
|
{activeTab === "tracks" && trackList.length > 0 && (<div className="space-y-4">
|
||||||
@@ -562,7 +607,7 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
|
|||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>Download All Covers</p>
|
<p>Download All Separate Covers</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>)}
|
</Tooltip>)}
|
||||||
{downloadedTracks.size > 0 && (<Button onClick={onOpenFolder} size="sm" variant="outline">
|
{downloadedTracks.size > 0 && (<Button onClick={onOpenFolder} size="sm" variant="outline">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Activity, Waves, Radio, TrendingUp, FileAudio, Clock, Gauge, HardDrive } from "lucide-react";
|
import { Activity } from "lucide-react";
|
||||||
import type { AnalysisResult } from "@/types/api";
|
import type { AnalysisResult } from "@/types/api";
|
||||||
interface AudioAnalysisProps {
|
interface AudioAnalysisProps {
|
||||||
result: AnalysisResult | null;
|
result: AnalysisResult | null;
|
||||||
@@ -13,32 +13,32 @@ interface AudioAnalysisProps {
|
|||||||
export function AudioAnalysis({ result, analyzing, onAnalyze, showAnalyzeButton = true, filePath }: AudioAnalysisProps) {
|
export function AudioAnalysis({ result, analyzing, onAnalyze, showAnalyzeButton = true, filePath }: AudioAnalysisProps) {
|
||||||
if (analyzing) {
|
if (analyzing) {
|
||||||
return (<Card>
|
return (<Card>
|
||||||
<CardContent className="px-6">
|
<CardContent className="px-6">
|
||||||
<div className="flex items-center justify-center py-8 gap-3">
|
<div className="flex items-center justify-center py-8 gap-3">
|
||||||
<Spinner />
|
<Spinner />
|
||||||
<span className="text-muted-foreground">Analyzing audio quality...</span>
|
<span className="text-muted-foreground">Analyzing audio quality...</span>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>);
|
</Card>);
|
||||||
}
|
}
|
||||||
if (!result && showAnalyzeButton) {
|
if (!result && showAnalyzeButton) {
|
||||||
return (<Card>
|
return (<Card>
|
||||||
<CardContent className="px-6">
|
<CardContent className="px-6">
|
||||||
<div className="flex flex-col items-center justify-center py-8 gap-4">
|
<div className="flex flex-col items-center justify-center py-8 gap-4">
|
||||||
<Activity className="h-12 w-12 text-primary"/>
|
<Activity className="h-12 w-12 text-primary"/>
|
||||||
<div className="text-center space-y-2">
|
<div className="text-center space-y-2">
|
||||||
<p className="font-medium">Audio Quality Analysis</p>
|
<p className="font-medium">Audio Quality Analysis</p>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Verify the true lossless quality of downloaded files
|
Inspect spectral content and effective quality of FLAC, MP3, M4A, and AAC files
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{onAnalyze && (<Button onClick={onAnalyze}>
|
{onAnalyze && (<Button onClick={onAnalyze}>
|
||||||
<Activity className="h-4 w-4"/>
|
<Activity className="h-4 w-4"/>
|
||||||
Analyze Audio
|
Analyze Audio
|
||||||
</Button>)}
|
</Button>)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>);
|
</Card>);
|
||||||
}
|
}
|
||||||
if (!result) {
|
if (!result) {
|
||||||
return null;
|
return null;
|
||||||
@@ -46,7 +46,7 @@ export function AudioAnalysis({ result, analyzing, onAnalyze, showAnalyzeButton
|
|||||||
const formatDuration = (seconds: number) => {
|
const formatDuration = (seconds: number) => {
|
||||||
const mins = Math.floor(seconds / 60);
|
const mins = Math.floor(seconds / 60);
|
||||||
const secs = Math.floor(seconds % 60);
|
const secs = Math.floor(seconds % 60);
|
||||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
return `${mins}:${secs.toString().padStart(2, "0")}`;
|
||||||
};
|
};
|
||||||
const formatNumber = (num: number) => {
|
const formatNumber = (num: number) => {
|
||||||
return num.toFixed(2);
|
return num.toFixed(2);
|
||||||
@@ -60,66 +60,120 @@ export function AudioAnalysis({ result, analyzing, onAnalyze, showAnalyzeButton
|
|||||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
|
||||||
};
|
};
|
||||||
const nyquistFreq = result.sample_rate / 2;
|
const nyquistFreq = result.sample_rate / 2;
|
||||||
|
const totalSamplesText = result.total_samples > 0 ? result.total_samples.toLocaleString() : "N/A";
|
||||||
|
const freqResolutionLabel = result.file_type === "MP3" ? "Freq Res.:" : "Freq Resolution:";
|
||||||
|
const hasCodecMeta = result.file_type === "MP3" && (Boolean(result.codec_mode) ||
|
||||||
|
typeof result.bitrate_kbps === "number" ||
|
||||||
|
typeof result.total_frames === "number" ||
|
||||||
|
Boolean(result.codec_version));
|
||||||
return (<Card className="gap-2">
|
return (<Card className="gap-2">
|
||||||
<CardHeader>
|
<CardHeader className="pb-2">
|
||||||
{filePath && (<p className="text-sm font-mono break-all">{filePath}</p>)}
|
{filePath && (<p className="text-sm font-mono break-all text-muted-foreground">{filePath}</p>)}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className="space-y-2">
|
<CardContent>
|
||||||
|
<div className={`grid grid-cols-1 gap-6 md:grid-cols-2 ${hasCodecMeta ? "lg:grid-cols-4" : "lg:grid-cols-3"}`}>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs font-semibold tracking-wider text-muted-foreground uppercase">Format</p>
|
||||||
|
<ul className="text-sm space-y-1">
|
||||||
|
{result.file_type && (<li className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Type:</span>
|
||||||
|
<span className="font-medium font-mono">{result.file_type}</span>
|
||||||
|
</li>)}
|
||||||
|
<li className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Sample Rate:</span>
|
||||||
|
<span className="font-medium font-mono">{(result.sample_rate / 1000).toFixed(1)} kHz</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Bit Depth:</span>
|
||||||
|
<span className="font-medium font-mono">{result.bit_depth}</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Channels:</span>
|
||||||
|
<span className="font-medium font-mono">{result.channels === 2 ? "Stereo" : result.channels === 1 ? "Mono" : `${result.channels}`}</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Duration:</span>
|
||||||
|
<span className="font-medium font-mono">{formatDuration(result.duration)}</span>
|
||||||
|
</li>
|
||||||
|
{result.file_size > 0 && (<li className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Size:</span>
|
||||||
|
<span className="font-medium font-mono">{formatFileSize(result.file_size)}</span>
|
||||||
|
</li>)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-1">
|
<p className="text-xs font-semibold tracking-wider text-muted-foreground uppercase">Signal Analytics</p>
|
||||||
<Radio className="h-3 w-3 text-muted-foreground"/>
|
<ul className="text-sm space-y-1">
|
||||||
<span className="text-muted-foreground">Sample Rate:</span>
|
<li className="flex justify-between">
|
||||||
<span className="font-semibold">{(result.sample_rate / 1000).toFixed(1)} kHz</span>
|
<span className="text-muted-foreground">Nyquist:</span>
|
||||||
</div>
|
<span className="font-medium font-mono">{(nyquistFreq / 1000).toFixed(1)} kHz</span>
|
||||||
<div className="flex items-center gap-1">
|
</li>
|
||||||
<FileAudio className="h-3 w-3 text-muted-foreground"/>
|
<li className="flex justify-between">
|
||||||
<span className="text-muted-foreground">Bit Depth:</span>
|
<span className="text-muted-foreground">Dynamic Range:</span>
|
||||||
<span className="font-semibold">{result.bit_depth}</span>
|
<span className="font-medium font-mono">{formatNumber(result.dynamic_range)} dB</span>
|
||||||
</div>
|
</li>
|
||||||
<div className="flex items-center gap-1">
|
<li className="flex justify-between">
|
||||||
<Waves className="h-3 w-3 text-muted-foreground"/>
|
<span className="text-muted-foreground">Peak Amplitude:</span>
|
||||||
<span className="text-muted-foreground">Channels:</span>
|
<span className="font-medium font-mono">{formatNumber(result.peak_amplitude)} dB</span>
|
||||||
<span className="font-semibold">{result.channels === 2 ? "Stereo" : result.channels === 1 ? "Mono" : `${result.channels}`}</span>
|
</li>
|
||||||
</div>
|
<li className="flex justify-between">
|
||||||
<div className="flex items-center gap-1">
|
<span className="text-muted-foreground">RMS Level:</span>
|
||||||
<Clock className="h-3 w-3 text-muted-foreground"/>
|
<span className="font-medium font-mono">{formatNumber(result.rms_level)} dB</span>
|
||||||
<span className="text-muted-foreground">Duration:</span>
|
</li>
|
||||||
<span className="font-semibold">{formatDuration(result.duration)}</span>
|
<li className="flex justify-between">
|
||||||
</div>
|
<span className="text-muted-foreground">Total Samples:</span>
|
||||||
<div className="flex items-center gap-1">
|
<span className="font-medium font-mono">{totalSamplesText}</span>
|
||||||
<Gauge className="h-3 w-3 text-muted-foreground"/>
|
</li>
|
||||||
<span className="text-muted-foreground">Nyquist:</span>
|
</ul>
|
||||||
<span className="font-semibold">{(nyquistFreq / 1000).toFixed(1)} kHz</span>
|
</div>
|
||||||
</div>
|
|
||||||
{result.file_size > 0 && (<div className="flex items-center gap-1">
|
|
||||||
<HardDrive className="h-3 w-3 text-muted-foreground"/>
|
|
||||||
<span className="text-muted-foreground">Size:</span>
|
|
||||||
<span className="font-semibold">{formatFileSize(result.file_size)}</span>
|
|
||||||
</div>)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
{hasCodecMeta && (<div className="space-y-2">
|
||||||
|
<p className="text-xs font-semibold tracking-wider text-muted-foreground uppercase">MP3 Meta</p>
|
||||||
|
<ul className="text-sm space-y-1">
|
||||||
|
{result.codec_mode && (<li className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Mode:</span>
|
||||||
|
<span className="font-medium font-mono">{result.codec_mode}</span>
|
||||||
|
</li>)}
|
||||||
|
{typeof result.bitrate_kbps === "number" && (<li className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Bitrate:</span>
|
||||||
|
<span className="font-medium font-mono">{result.bitrate_kbps} kbps</span>
|
||||||
|
</li>)}
|
||||||
|
{typeof result.total_frames === "number" && result.total_frames > 0 && (<li className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Frames:</span>
|
||||||
|
<span className="font-medium font-mono">{result.total_frames.toLocaleString()}</span>
|
||||||
|
</li>)}
|
||||||
|
{result.codec_version && (<li className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Version:</span>
|
||||||
|
<span className="font-medium font-mono">{result.codec_version}</span>
|
||||||
|
</li>)}
|
||||||
|
</ul>
|
||||||
|
</div>)}
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs border-t pt-2">
|
{result.spectrum && (() => {
|
||||||
<div className="flex items-center gap-1">
|
const frames = result.spectrum.time_slices.length;
|
||||||
<TrendingUp className="h-3 w-3 text-muted-foreground"/>
|
const fftSize = (result.spectrum.freq_bins - 1) * 2;
|
||||||
<span className="text-muted-foreground">Dynamic Range:</span>
|
const freqRes = result.sample_rate / fftSize;
|
||||||
<span className="font-semibold">{formatNumber(result.dynamic_range)} dB</span>
|
return (<div className="space-y-2">
|
||||||
</div>
|
<p className="text-xs font-semibold tracking-wider text-muted-foreground uppercase">Spectrum Meta</p>
|
||||||
<div className="flex items-center gap-1">
|
<ul className="text-sm space-y-1">
|
||||||
<span className="text-muted-foreground">Peak:</span>
|
<li className="flex justify-between">
|
||||||
<span className="font-semibold">{formatNumber(result.peak_amplitude)} dB</span>
|
<span className="text-muted-foreground">Display Frames:</span>
|
||||||
</div>
|
<span className="font-medium font-mono">{frames.toLocaleString()}</span>
|
||||||
<div className="flex items-center gap-1">
|
</li>
|
||||||
<span className="text-muted-foreground">RMS:</span>
|
<li className="flex justify-between">
|
||||||
<span className="font-semibold">{formatNumber(result.rms_level)} dB</span>
|
<span className="text-muted-foreground">FFT Size:</span>
|
||||||
</div>
|
<span className="font-medium font-mono">{fftSize.toLocaleString()}</span>
|
||||||
<div className="flex items-center gap-1 ml-auto">
|
</li>
|
||||||
<span className="text-muted-foreground">Samples:</span>
|
<li className="flex justify-between">
|
||||||
<span className="font-semibold">{result.total_samples.toLocaleString()}</span>
|
<span className="text-muted-foreground">{freqResolutionLabel}</span>
|
||||||
</div>
|
<span className="font-medium font-mono">{freqRes.toFixed(2)} Hz/bin</span>
|
||||||
</div>
|
</li>
|
||||||
</CardContent>
|
</ul>
|
||||||
</Card>);
|
</div>);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,72 +1,187 @@
|
|||||||
import { useState, useCallback, useEffect } from "react";
|
import { useState, useCallback, useRef, useEffect, type ChangeEvent, type DragEvent, type CSSProperties } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Upload, ArrowLeft, Trash2 } from "lucide-react";
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
import { Upload, ArrowLeft, Trash2, Download } from "lucide-react";
|
||||||
import { AudioAnalysis } from "@/components/AudioAnalysis";
|
import { AudioAnalysis } from "@/components/AudioAnalysis";
|
||||||
import { SpectrumVisualization } from "@/components/SpectrumVisualization";
|
import { SpectrumVisualization } from "@/components/SpectrumVisualization";
|
||||||
import { useAudioAnalysis } from "@/hooks/useAudioAnalysis";
|
import { useAudioAnalysis } from "@/hooks/useAudioAnalysis";
|
||||||
import { SelectFile } from "../../wailsjs/go/main/App";
|
|
||||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||||
|
import { SelectFile, SaveSpectrumImage } from "../../wailsjs/go/main/App";
|
||||||
import { OnFileDrop, OnFileDropOff } from "../../wailsjs/runtime/runtime";
|
import { OnFileDrop, OnFileDropOff } from "../../wailsjs/runtime/runtime";
|
||||||
interface AudioAnalysisPageProps {
|
interface AudioAnalysisPageProps {
|
||||||
onBack?: () => void;
|
onBack?: () => void;
|
||||||
}
|
}
|
||||||
|
const SUPPORTED_AUDIO_EXTENSIONS = [".flac", ".mp3", ".m4a", ".aac"];
|
||||||
|
const SUPPORTED_AUDIO_ACCEPT = [
|
||||||
|
".flac",
|
||||||
|
".mp3",
|
||||||
|
".m4a",
|
||||||
|
".aac",
|
||||||
|
"audio/flac",
|
||||||
|
"audio/x-flac",
|
||||||
|
"audio/mpeg",
|
||||||
|
"audio/mp3",
|
||||||
|
"audio/mp4",
|
||||||
|
"audio/x-m4a",
|
||||||
|
"audio/aac",
|
||||||
|
"audio/aacp",
|
||||||
|
].join(",");
|
||||||
|
const SUPPORTED_AUDIO_LABEL = "FLAC, MP3, M4A, or AAC";
|
||||||
|
function isSupportedAudioPath(filePath: string): boolean {
|
||||||
|
const normalized = filePath.toLowerCase();
|
||||||
|
return SUPPORTED_AUDIO_EXTENSIONS.some((ext) => normalized.endsWith(ext));
|
||||||
|
}
|
||||||
|
function isSupportedAudioFile(file: File): boolean {
|
||||||
|
const normalizedName = file.name.toLowerCase();
|
||||||
|
const normalizedType = file.type.toLowerCase();
|
||||||
|
return (SUPPORTED_AUDIO_EXTENSIONS.some((ext) => normalizedName.endsWith(ext)) ||
|
||||||
|
normalizedType === "audio/flac" ||
|
||||||
|
normalizedType === "audio/x-flac" ||
|
||||||
|
normalizedType === "audio/mpeg" ||
|
||||||
|
normalizedType === "audio/mp3" ||
|
||||||
|
normalizedType === "audio/mp4" ||
|
||||||
|
normalizedType === "audio/x-m4a" ||
|
||||||
|
normalizedType === "audio/aac" ||
|
||||||
|
normalizedType === "audio/aacp");
|
||||||
|
}
|
||||||
|
function isAbsolutePath(filePath: string): boolean {
|
||||||
|
return /^(?:[a-zA-Z]:[\\/]|\\\\|\/)/.test(filePath);
|
||||||
|
}
|
||||||
|
function fileNameFromPath(filePath: string): string {
|
||||||
|
const parts = filePath.split(/[/\\]/);
|
||||||
|
return parts[parts.length - 1] || filePath;
|
||||||
|
}
|
||||||
export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
|
export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
|
||||||
const { analyzing, result, analyzeFile, clearResult, selectedFilePath, spectrumLoading } = useAudioAnalysis();
|
const { analyzing, analysisProgress, result, analyzeFile, analyzeFilePath, clearResult, selectedFilePath, spectrumLoading, spectrumProgress, reAnalyzeSpectrum, } = useAudioAnalysis();
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
const handleSelectFile = async () => {
|
const [isExporting, setIsExporting] = useState(false);
|
||||||
|
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`,
|
||||||
|
});
|
||||||
|
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`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await analyzeFile(file);
|
||||||
|
}, [analyzeFile]);
|
||||||
|
const handleSelectFile = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const filePath = await SelectFile();
|
const filePath = await SelectFile();
|
||||||
if (filePath) {
|
if (!filePath) {
|
||||||
await analyzeFile(filePath);
|
return;
|
||||||
}
|
}
|
||||||
|
await analyzeSelectedPath(filePath);
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch {
|
||||||
toast.error("File Selection Failed", {
|
fileInputRef.current?.click();
|
||||||
description: err instanceof Error ? err.message : "Failed to select file",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
}, [analyzeSelectedPath]);
|
||||||
const handleFileDrop = useCallback(async (_x: number, _y: number, paths: string[]) => {
|
const handleInputChange = useCallback(async (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file)
|
||||||
|
return;
|
||||||
|
await analyzeSelectedFile(file);
|
||||||
|
e.target.value = "";
|
||||||
|
}, [analyzeSelectedFile]);
|
||||||
|
const handleHtmlDrop = useCallback(async (e: DragEvent<HTMLDivElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
if (paths.length === 0)
|
const file = e.dataTransfer.files?.[0];
|
||||||
|
if (!file)
|
||||||
return;
|
return;
|
||||||
const filePath = paths[0];
|
await analyzeSelectedFile(file);
|
||||||
if (!filePath.toLowerCase().endsWith(".flac")) {
|
}, [analyzeSelectedFile]);
|
||||||
toast.error("Invalid File Type", {
|
|
||||||
description: "Please drop a FLAC file for analysis",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await analyzeFile(filePath);
|
|
||||||
}, [analyzeFile]);
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
OnFileDrop((x, y, paths) => {
|
OnFileDrop((_x, _y, paths) => {
|
||||||
handleFileDrop(x, y, paths);
|
setIsDragging(false);
|
||||||
|
const droppedPath = paths?.[0];
|
||||||
|
if (!droppedPath)
|
||||||
|
return;
|
||||||
|
void analyzeSelectedPath(droppedPath);
|
||||||
}, true);
|
}, true);
|
||||||
return () => {
|
return () => {
|
||||||
OnFileDropOff();
|
OnFileDropOff();
|
||||||
};
|
};
|
||||||
}, [handleFileDrop]);
|
}, [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" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsExporting(true);
|
||||||
|
try {
|
||||||
|
if (selectedFilePath && isAbsolutePath(selectedFilePath)) {
|
||||||
|
const outPath = await SaveSpectrumImage(selectedFilePath, dataUrl);
|
||||||
|
toast.success("Exported Successfully", {
|
||||||
|
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", {
|
||||||
|
description: "Spectrogram image downloaded",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
toast.error("Export Failed", {
|
||||||
|
description: err instanceof Error ? err.message : "Failed to export image",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
setIsExporting(false);
|
||||||
|
}
|
||||||
|
}, [selectedFilePath]);
|
||||||
const handleAnalyzeAnother = () => {
|
const handleAnalyzeAnother = () => {
|
||||||
clearResult();
|
clearResult();
|
||||||
};
|
};
|
||||||
|
const fileName = selectedFilePath ? fileNameFromPath(selectedFilePath) : undefined;
|
||||||
return (<div className="space-y-6">
|
return (<div className="space-y-6">
|
||||||
|
<input ref={fileInputRef} type="file" accept={SUPPORTED_AUDIO_ACCEPT} className="hidden" onChange={handleInputChange}/>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
{onBack && (<Button variant="ghost" size="icon" onClick={onBack}>
|
{onBack && (<Button variant="ghost" size="icon" onClick={onBack}>
|
||||||
<ArrowLeft className="h-5 w-5"/>
|
<ArrowLeft className="h-5 w-5"/>
|
||||||
</Button>)}
|
</Button>)}
|
||||||
<h1 className="text-2xl font-bold">Audio Quality Analyzer</h1>
|
<h1 className="text-2xl font-bold">Audio Quality Analyzer</h1>
|
||||||
</div>
|
</div>
|
||||||
{result && (<Button onClick={handleAnalyzeAnother} variant="outline" size="sm">
|
{result && (<div className="flex gap-2">
|
||||||
<Trash2 className="h-4 w-4"/>
|
<Button onClick={handleExport} variant="outline" size="sm" disabled={isExporting || spectrumLoading}>
|
||||||
Clear
|
<Download className="h-4 w-4 mr-1"/>
|
||||||
</Button>)}
|
{isExporting ? "Exporting..." : "Export PNG"}
|
||||||
</div>
|
</Button>
|
||||||
|
<Button onClick={handleAnalyzeAnother} variant="outline" size="sm">
|
||||||
|
<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
|
||||||
{!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-primary bg-primary/10"
|
||||||
: "border-muted-foreground/30"}`} onDragOver={(e) => {
|
: "border-muted-foreground/30"}`} onDragOver={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -74,40 +189,38 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
|
|||||||
}} onDragLeave={(e) => {
|
}} onDragLeave={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
}} onDrop={(e) => {
|
}} onDrop={handleHtmlDrop} style={{ "--wails-drop-target": "drop" } as CSSProperties}>
|
||||||
e.preventDefault();
|
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted">
|
||||||
setIsDragging(false);
|
<Upload className="h-8 w-8 text-primary"/>
|
||||||
}} style={{ "--wails-drop-target": "drop" } as React.CSSProperties}>
|
</div>
|
||||||
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted">
|
<p className="text-sm text-muted-foreground mb-4 text-center">
|
||||||
<Upload className="h-8 w-8 text-primary"/>
|
{isDragging
|
||||||
</div>
|
? "Drop your audio file here"
|
||||||
<p className="text-sm text-muted-foreground mb-4 text-center">
|
: "Drag and drop an audio file here, or click the button below to select"}
|
||||||
{isDragging
|
</p>
|
||||||
? "Drop your FLAC file here"
|
<Button onClick={handleSelectFile} size="lg">
|
||||||
: "Drag and drop a FLAC file here, or click the button below to select"}
|
<Upload className="h-5 w-5"/>
|
||||||
</p>
|
Select Audio File
|
||||||
<Button onClick={handleSelectFile} size="lg">
|
</Button>
|
||||||
<Upload className="h-5 w-5"/>
|
<p className="text-xs text-muted-foreground mt-4 text-center">
|
||||||
Select FLAC File
|
Supported formats: FLAC, MP3, M4A, AAC
|
||||||
</Button>
|
</p>
|
||||||
</div>)}
|
</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>
|
||||||
|
</div>)}
|
||||||
|
|
||||||
{analyzing && !result && (<div className="flex flex-col items-center justify-center py-16">
|
{result && (<div className="space-y-4">
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mb-4"></div>
|
<AudioAnalysis result={result} analyzing={analyzing} showAnalyzeButton={false} filePath={selectedFilePath}/>
|
||||||
<p className="text-sm text-muted-foreground">Analyzing audio file...</p>
|
|
||||||
</div>)}
|
|
||||||
|
|
||||||
|
<SpectrumVisualization ref={spectrumRef} sampleRate={result.sample_rate} duration={result.duration} spectrumData={result.spectrum} fileName={fileName} onReAnalyze={reAnalyzeSpectrum} isAnalyzingSpectrum={spectrumLoading} spectrumProgress={spectrumProgress}/>
|
||||||
{result && (<div className="space-y-4">
|
</div>)}
|
||||||
|
</div>);
|
||||||
<AudioAnalysis result={result} analyzing={analyzing} showAnalyzeButton={false} filePath={selectedFilePath}/>
|
|
||||||
|
|
||||||
|
|
||||||
{spectrumLoading ? (<div className="flex flex-col items-center justify-center py-16 border rounded-lg">
|
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mb-2"></div>
|
|
||||||
<p className="text-sm text-muted-foreground">Loading spectrum data...</p>
|
|
||||||
</div>) : (<SpectrumVisualization sampleRate={result.sample_rate} bitsPerSample={result.bits_per_sample} duration={result.duration} spectrumData={result.spectrum}/>)}
|
|
||||||
</div>)}
|
|
||||||
</div>);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,468 @@
|
|||||||
|
import { useState, useCallback, useEffect } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||||
|
import { Upload, X, CheckCircle2, AlertCircle, Trash2, FileMusic } from "lucide-react";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
import { SelectAudioFiles, SelectFolder, ListAudioFilesInDir, ResampleAudio } from "../../wailsjs/go/main/App";
|
||||||
|
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||||
|
import { OnFileDrop, OnFileDropOff } from "../../wailsjs/runtime/runtime";
|
||||||
|
import { AudioLinesIcon } from "@/components/ui/audio-lines";
|
||||||
|
interface AudioFile {
|
||||||
|
path: string;
|
||||||
|
name: string;
|
||||||
|
format: string;
|
||||||
|
size: number;
|
||||||
|
status: "pending" | "resampling" | "success" | "error";
|
||||||
|
error?: string;
|
||||||
|
outputPath?: string;
|
||||||
|
srcSampleRate?: number;
|
||||||
|
srcBitDepth?: number;
|
||||||
|
}
|
||||||
|
function formatFileSize(bytes: number): string {
|
||||||
|
if (bytes === 0)
|
||||||
|
return "0 B";
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ["B", "KB", "MB", "GB"];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
|
||||||
|
}
|
||||||
|
function formatSampleRate(sr: number): string {
|
||||||
|
if (!sr)
|
||||||
|
return "";
|
||||||
|
if (sr === 44100)
|
||||||
|
return "44.1kHz";
|
||||||
|
if (sr >= 1000)
|
||||||
|
return `${sr / 1000}kHz`;
|
||||||
|
return `${sr}Hz`;
|
||||||
|
}
|
||||||
|
const SAMPLE_RATE_OPTIONS = [
|
||||||
|
{ value: "44100", label: "44.1kHz" },
|
||||||
|
{ value: "48000", label: "48kHz" },
|
||||||
|
{ value: "96000", label: "96kHz" },
|
||||||
|
{ value: "192000", label: "192kHz" },
|
||||||
|
];
|
||||||
|
const BIT_DEPTH_OPTIONS = [
|
||||||
|
{ value: "16", label: "16-bit" },
|
||||||
|
{ value: "24", label: "24-bit" },
|
||||||
|
];
|
||||||
|
const STORAGE_KEY = "spotiflac_audio_resampler_state";
|
||||||
|
export function AudioResamplerPage() {
|
||||||
|
const [files, setFiles] = useState<AudioFile[]>(() => {
|
||||||
|
try {
|
||||||
|
const saved = sessionStorage.getItem(STORAGE_KEY);
|
||||||
|
if (saved) {
|
||||||
|
const parsed = JSON.parse(saved);
|
||||||
|
if (parsed.files && Array.isArray(parsed.files) && parsed.files.length > 0) {
|
||||||
|
return parsed.files;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.error("Failed to load saved state:", err);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
const [sampleRate, setSampleRate] = useState(() => {
|
||||||
|
try {
|
||||||
|
const saved = sessionStorage.getItem(STORAGE_KEY);
|
||||||
|
if (saved) {
|
||||||
|
const parsed = JSON.parse(saved);
|
||||||
|
if (parsed.sampleRate)
|
||||||
|
return parsed.sampleRate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
}
|
||||||
|
return "44100";
|
||||||
|
});
|
||||||
|
const [bitDepth, setBitDepth] = useState(() => {
|
||||||
|
try {
|
||||||
|
const saved = sessionStorage.getItem(STORAGE_KEY);
|
||||||
|
if (saved) {
|
||||||
|
const parsed = JSON.parse(saved);
|
||||||
|
if (parsed.bitDepth)
|
||||||
|
return parsed.bitDepth;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
}
|
||||||
|
return "16";
|
||||||
|
});
|
||||||
|
const [resampling, setResampling] = useState(false);
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
|
const saveState = useCallback((stateToSave: {
|
||||||
|
files: AudioFile[];
|
||||||
|
sampleRate: string;
|
||||||
|
bitDepth: string;
|
||||||
|
}) => {
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(stateToSave));
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.error("Failed to save state:", err);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
useEffect(() => {
|
||||||
|
saveState({ files, sampleRate, bitDepth });
|
||||||
|
}, [files, sampleRate, bitDepth, saveState]);
|
||||||
|
useEffect(() => {
|
||||||
|
const checkFullscreen = () => {
|
||||||
|
const isMaximized = window.innerHeight >= window.screen.height * 0.9;
|
||||||
|
setIsFullscreen(isMaximized);
|
||||||
|
};
|
||||||
|
checkFullscreen();
|
||||||
|
window.addEventListener("resize", checkFullscreen);
|
||||||
|
window.addEventListener("focus", checkFullscreen);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("resize", checkFullscreen);
|
||||||
|
window.removeEventListener("focus", checkFullscreen);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
const fetchAudioInfo = useCallback(async (paths: string[]) => {
|
||||||
|
if (paths.length === 0)
|
||||||
|
return;
|
||||||
|
try {
|
||||||
|
const GetFlacInfoBatch = (window as any)["go"]["main"]["App"]["GetFlacInfoBatch"];
|
||||||
|
const infos: Array<{
|
||||||
|
path: string;
|
||||||
|
sample_rate: number;
|
||||||
|
bits_per_sample: number;
|
||||||
|
}> = await GetFlacInfoBatch(paths);
|
||||||
|
setFiles((prev) => prev.map((f) => {
|
||||||
|
const info = infos.find((i) => i.path === f.path || i.path.toLowerCase() === f.path.toLowerCase());
|
||||||
|
if (info) {
|
||||||
|
return {
|
||||||
|
...f,
|
||||||
|
srcSampleRate: info.sample_rate || undefined,
|
||||||
|
srcBitDepth: info.bits_per_sample || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return f;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.error("Failed to fetch audio info:", err);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
const handleSelectFiles = async () => {
|
||||||
|
try {
|
||||||
|
const selectedFiles = await SelectAudioFiles();
|
||||||
|
if (selectedFiles && selectedFiles.length > 0) {
|
||||||
|
addFiles(selectedFiles);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
toast.error("File Selection Failed", {
|
||||||
|
description: err instanceof Error ? err.message : "Failed to select files",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleSelectFolder = async () => {
|
||||||
|
try {
|
||||||
|
const selectedFolder = await SelectFolder("");
|
||||||
|
if (selectedFolder) {
|
||||||
|
const folderFiles = await ListAudioFilesInDir(selectedFolder);
|
||||||
|
if (folderFiles && folderFiles.length > 0) {
|
||||||
|
addFiles(folderFiles.map((f) => f.path));
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
toast.info("No audio files found", {
|
||||||
|
description: "No FLAC files found in the selected folder.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
toast.error("Folder Selection Failed", {
|
||||||
|
description: err instanceof Error ? err.message : "Failed to select folder",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const addFiles = useCallback(async (paths: string[]) => {
|
||||||
|
const validExtensions = [".flac"];
|
||||||
|
const invalidFiles = paths.filter((path) => {
|
||||||
|
const ext = path.toLowerCase().slice(path.lastIndexOf("."));
|
||||||
|
return !validExtensions.includes(ext);
|
||||||
|
});
|
||||||
|
if (invalidFiles.length > 0) {
|
||||||
|
toast.error("Unsupported format", {
|
||||||
|
description: "Only FLAC files are supported for resampling.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const GetFileSizes = (files: string[]): Promise<Record<string, number>> => (window as any)["go"]["main"]["App"]["GetFileSizes"](files);
|
||||||
|
const validPaths = paths.filter((path) => {
|
||||||
|
const ext = path.toLowerCase().slice(path.lastIndexOf("."));
|
||||||
|
return validExtensions.includes(ext);
|
||||||
|
});
|
||||||
|
const fileSizes = validPaths.length > 0 ? await GetFileSizes(validPaths) : {};
|
||||||
|
let newlyAddedPaths: string[] = [];
|
||||||
|
setFiles((prev) => {
|
||||||
|
const newFiles: AudioFile[] = validPaths
|
||||||
|
.filter((path) => !prev.some((f) => f.path === path))
|
||||||
|
.map((path) => {
|
||||||
|
const name = path.split(/[/\\]/).pop() || path;
|
||||||
|
const ext = name.slice(name.lastIndexOf(".") + 1).toLowerCase();
|
||||||
|
return {
|
||||||
|
path,
|
||||||
|
name,
|
||||||
|
format: ext,
|
||||||
|
size: fileSizes[path] || 0,
|
||||||
|
status: "pending" as const,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
newlyAddedPaths = newFiles.map((f) => f.path);
|
||||||
|
if (newFiles.length > 0) {
|
||||||
|
if (paths.length > newFiles.length + invalidFiles.length) {
|
||||||
|
const skipped = paths.length - newFiles.length - invalidFiles.length;
|
||||||
|
toast.info("Some files skipped", {
|
||||||
|
description: `${skipped} file(s) were already added`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return [...prev, ...newFiles];
|
||||||
|
}
|
||||||
|
if (validPaths.length > 0 && newFiles.length === 0) {
|
||||||
|
toast.info("No new files added", {
|
||||||
|
description: "All valid files were already added",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
setTimeout(() => {
|
||||||
|
if (newlyAddedPaths.length > 0) {
|
||||||
|
fetchAudioInfo(newlyAddedPaths);
|
||||||
|
}
|
||||||
|
}, 50);
|
||||||
|
}, [fetchAudioInfo]);
|
||||||
|
const handleFileDrop = useCallback(async (_x: number, _y: number, paths: string[]) => {
|
||||||
|
setIsDragging(false);
|
||||||
|
if (paths.length === 0)
|
||||||
|
return;
|
||||||
|
addFiles(paths);
|
||||||
|
}, [addFiles]);
|
||||||
|
useEffect(() => {
|
||||||
|
OnFileDrop((x, y, paths) => {
|
||||||
|
handleFileDrop(x, y, paths);
|
||||||
|
}, true);
|
||||||
|
return () => {
|
||||||
|
OnFileDropOff();
|
||||||
|
};
|
||||||
|
}, [handleFileDrop]);
|
||||||
|
const removeFile = (path: string) => {
|
||||||
|
setFiles((prev) => prev.filter((f) => f.path !== path));
|
||||||
|
};
|
||||||
|
const clearFiles = () => {
|
||||||
|
setFiles([]);
|
||||||
|
};
|
||||||
|
const handleResample = async () => {
|
||||||
|
if (files.length === 0) {
|
||||||
|
toast.error("No files selected", {
|
||||||
|
description: "Please add FLAC files to resample",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setResampling(true);
|
||||||
|
try {
|
||||||
|
const inputPaths = files.map((f) => f.path);
|
||||||
|
setFiles((prev) => prev.map((f) => {
|
||||||
|
if (inputPaths.includes(f.path)) {
|
||||||
|
return { ...f, status: "resampling" as const, error: undefined };
|
||||||
|
}
|
||||||
|
return f;
|
||||||
|
}));
|
||||||
|
const results = await ResampleAudio({
|
||||||
|
input_files: inputPaths,
|
||||||
|
sample_rate: sampleRate,
|
||||||
|
bit_depth: bitDepth,
|
||||||
|
});
|
||||||
|
setFiles((prev) => prev.map((f) => {
|
||||||
|
const result = results.find((r: any) => r.input_file === f.path || r.input_file.toLowerCase() === f.path.toLowerCase());
|
||||||
|
if (result) {
|
||||||
|
return {
|
||||||
|
...f,
|
||||||
|
status: result.success ? "success" : "error",
|
||||||
|
error: result.error,
|
||||||
|
outputPath: result.output_file,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return f;
|
||||||
|
}));
|
||||||
|
const successCount = results.filter((r: any) => r.success).length;
|
||||||
|
const failCount = results.filter((r: any) => !r.success).length;
|
||||||
|
if (successCount > 0) {
|
||||||
|
toast.success("Resampling Complete", {
|
||||||
|
description: `Successfully resampled ${successCount} file(s)${failCount > 0 ? `, ${failCount} failed` : ""}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else if (failCount > 0) {
|
||||||
|
toast.error("Resampling Failed", {
|
||||||
|
description: `All ${failCount} file(s) failed to resample`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
toast.error("Resampling Error", {
|
||||||
|
description: err instanceof Error ? err.message : "Unknown error",
|
||||||
|
});
|
||||||
|
setFiles((prev) => prev.map((f) => ({ ...f, status: "error" as const, error: "Resampling failed" })));
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
setResampling(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const getStatusIcon = (status: AudioFile["status"]) => {
|
||||||
|
switch (status) {
|
||||||
|
case "resampling":
|
||||||
|
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"/>;
|
||||||
|
default:
|
||||||
|
return <FileMusic className="h-4 w-4 text-muted-foreground"/>;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const resampleableCount = files.filter((f) => f.status === "pending" || f.status === "success").length;
|
||||||
|
const successCount = files.filter((f) => f.status === "success").length;
|
||||||
|
return (<div className={`space-y-6 ${isFullscreen ? "h-full flex flex-col" : ""}`}>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-bold">Audio Resampler</h1>
|
||||||
|
{files.length > 0 && (<div className="flex gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={handleSelectFiles}>
|
||||||
|
<Upload className="h-4 w-4"/>
|
||||||
|
Add Files
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={handleSelectFolder}>
|
||||||
|
<Upload className="h-4 w-4"/>
|
||||||
|
Add Folder
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={clearFiles} disabled={resampling}>
|
||||||
|
<Trash2 className="h-4 w-4"/>
|
||||||
|
Clear All
|
||||||
|
</Button>
|
||||||
|
</div>)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`flex flex-col items-center justify-center border-2 border-dashed rounded-lg transition-all ${isFullscreen ? "flex-1 min-h-[400px]" : "h-[400px]"} ${isDragging
|
||||||
|
? "border-primary bg-primary/10"
|
||||||
|
: "border-muted-foreground/30"}`} onDragOver={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(true);
|
||||||
|
}} onDragLeave={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(false);
|
||||||
|
}} onDrop={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(false);
|
||||||
|
}} style={{ "--wails-drop-target": "drop" } as React.CSSProperties}>
|
||||||
|
{files.length === 0 ? (<>
|
||||||
|
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted">
|
||||||
|
<Upload className="h-8 w-8 text-primary"/>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4 text-center">
|
||||||
|
{isDragging
|
||||||
|
? "Drop your audio files here"
|
||||||
|
: "Drag and drop audio files here, or click the button below to select"}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button onClick={handleSelectFiles} size="lg">
|
||||||
|
<Upload className="h-5 w-5"/>
|
||||||
|
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 format: FLAC
|
||||||
|
</p>
|
||||||
|
</>) : (<div className="w-full h-full p-6 space-y-4 flex flex-col">
|
||||||
|
<div className="space-y-2 pb-4 border-b shrink-0">
|
||||||
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label className="whitespace-nowrap">Bit Depth:</Label>
|
||||||
|
<ToggleGroup type="single" variant="outline" value={bitDepth} onValueChange={(value) => {
|
||||||
|
if (value)
|
||||||
|
setBitDepth(value);
|
||||||
|
}}>
|
||||||
|
{BIT_DEPTH_OPTIONS.map((option) => (<ToggleGroupItem key={option.value} value={option.value} aria-label={option.label}>
|
||||||
|
{option.label}
|
||||||
|
</ToggleGroupItem>))}
|
||||||
|
</ToggleGroup>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label className="whitespace-nowrap">Sample Rate:</Label>
|
||||||
|
<ToggleGroup type="single" variant="outline" value={sampleRate} onValueChange={(value) => {
|
||||||
|
if (value)
|
||||||
|
setSampleRate(value);
|
||||||
|
}}>
|
||||||
|
{SAMPLE_RATE_OPTIONS.map((option) => (<ToggleGroupItem key={option.value} value={option.value} aria-label={option.label}>
|
||||||
|
{option.label}
|
||||||
|
</ToggleGroupItem>))}
|
||||||
|
</ToggleGroup>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between shrink-0">
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{files.length} file(s) • {successCount} resampled
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 space-y-2 overflow-y-auto min-h-0">
|
||||||
|
{files.map((file) => {
|
||||||
|
const srcParts: string[] = [];
|
||||||
|
if (file.srcBitDepth)
|
||||||
|
srcParts.push(`${file.srcBitDepth}-bit`);
|
||||||
|
if (file.srcSampleRate)
|
||||||
|
srcParts.push(formatSampleRate(file.srcSampleRate));
|
||||||
|
const srcSpec = srcParts.join(" / ");
|
||||||
|
return (<div key={file.path} className="flex items-center gap-3 rounded-lg border p-3">
|
||||||
|
{getStatusIcon(file.status)}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="truncate text-sm font-medium">{file.name}</p>
|
||||||
|
{file.error && (<p className="truncate text-xs text-destructive">
|
||||||
|
{file.error}
|
||||||
|
</p>)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{srcSpec ? (<span className="text-xs font-medium text-primary bg-primary/10 rounded px-1.5 py-0.5 whitespace-nowrap shrink-0">
|
||||||
|
{srcSpec}
|
||||||
|
</span>) : file.status === "pending" ? (<span className="text-xs text-muted-foreground/50 whitespace-nowrap shrink-0">
|
||||||
|
reading...
|
||||||
|
</span>) : null}
|
||||||
|
|
||||||
|
<span className="text-xs text-muted-foreground shrink-0">
|
||||||
|
{formatFileSize(file.size)}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs uppercase text-muted-foreground shrink-0">
|
||||||
|
{file.format}
|
||||||
|
</span>
|
||||||
|
{file.status !== "resampling" && (<Button variant="ghost" size="icon" className="h-8 w-8 shrink-0" onClick={() => removeFile(file.path)} disabled={resampling}>
|
||||||
|
<X className="h-4 w-4"/>
|
||||||
|
</Button>)}
|
||||||
|
</div>);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-center pt-4 border-t shrink-0">
|
||||||
|
<Button onClick={handleResample} disabled={resampling || resampleableCount === 0} size="lg">
|
||||||
|
{resampling ? (<>
|
||||||
|
<Spinner className="h-4 w-4"/>
|
||||||
|
Resampling...
|
||||||
|
</>) : (<>
|
||||||
|
<AudioLinesIcon size={16} className="text-primary-foreground"/>
|
||||||
|
Resample{" "}
|
||||||
|
{resampleableCount > 0 ? `${resampleableCount} File(s)` : ""}
|
||||||
|
</>)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>)}
|
||||||
|
</div>
|
||||||
|
</div>);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from "react";
|
|||||||
import { Trash2, Copy, Check, FileDown } from "lucide-react";
|
import { Trash2, Copy, Check, FileDown } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { logger, type LogEntry } from "@/lib/logger";
|
import { logger, type LogEntry } from "@/lib/logger";
|
||||||
|
import { useDownloadQueueData } from "@/hooks/useDownloadQueueData";
|
||||||
import { ExportFailedDownloads } from "../../wailsjs/go/main/App";
|
import { ExportFailedDownloads } from "../../wailsjs/go/main/App";
|
||||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||||
const levelColors: Record<string, string> = {
|
const levelColors: Record<string, string> = {
|
||||||
@@ -23,6 +24,13 @@ export function DebugLoggerPage() {
|
|||||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const queueInfo = useDownloadQueueData();
|
||||||
|
const hasDownloadActivity = queueInfo.queue.length > 0 ||
|
||||||
|
queueInfo.queued_count > 0 ||
|
||||||
|
queueInfo.completed_count > 0 ||
|
||||||
|
queueInfo.failed_count > 0 ||
|
||||||
|
queueInfo.skipped_count > 0;
|
||||||
|
const canExportFailed = hasDownloadActivity && queueInfo.failed_count > 0;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsubscribe = logger.subscribe(() => {
|
const unsubscribe = logger.subscribe(() => {
|
||||||
setLogs(logger.getLogs());
|
setLogs(logger.getLogs());
|
||||||
@@ -54,6 +62,9 @@ export function DebugLoggerPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
const handleExportFailed = async () => {
|
const handleExportFailed = async () => {
|
||||||
|
if (!canExportFailed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const message = await ExportFailedDownloads();
|
const message = await ExportFailedDownloads();
|
||||||
if (message.startsWith("Successfully")) {
|
if (message.startsWith("Successfully")) {
|
||||||
@@ -72,7 +83,7 @@ export function DebugLoggerPage() {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-bold">Debug Logs</h1>
|
<h1 className="text-2xl font-bold">Debug Logs</h1>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button variant="outline" size="sm" className="gap-1.5" onClick={handleExportFailed}>
|
<Button variant="outline" size="sm" className="gap-1.5" onClick={handleExportFailed} disabled={!canExportFailed}>
|
||||||
<FileDown className="h-4 w-4"/>
|
<FileDown className="h-4 w-4"/>
|
||||||
Export Failed
|
Export Failed
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -13,18 +13,18 @@ export function DownloadProgressToast({ onClick }: DownloadProgressToastProps) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return (<div className="fixed bottom-4 left-[calc(56px+1rem)] z-50 animate-in slide-in-from-bottom-5 data-[state=closed]:animate-out data-[state=closed]:slide-out-to-bottom-5">
|
return (<div className="fixed bottom-4 left-[calc(56px+1rem)] z-50 animate-in slide-in-from-bottom-5 data-[state=closed]:animate-out data-[state=closed]:slide-out-to-bottom-5">
|
||||||
<Button variant="outline" className="bg-background border rounded-lg shadow-lg p-3 h-auto hover:bg-muted/50 transition-colors cursor-pointer" onClick={onClick}>
|
<Button variant="outline" className="h-auto cursor-pointer rounded-lg border-border bg-background p-3 text-foreground shadow-lg transition-colors hover:bg-muted dark:border-blue-800 dark:bg-blue-950 dark:text-blue-100 dark:hover:bg-blue-900" onClick={onClick}>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Download className={`h-4 w-4 text-primary ${progress.is_downloading ? 'animate-bounce' : ''}`}/>
|
<Download className={`h-4 w-4 text-blue-600 dark:text-blue-400 ${progress.is_downloading ? 'animate-bounce' : ''}`}/>
|
||||||
<div className="flex flex-col min-w-[80px]">
|
<div className="flex flex-col min-w-[80px]">
|
||||||
<p className="text-sm font-medium font-mono tabular-nums">
|
<p className="text-sm font-medium font-mono tabular-nums">
|
||||||
{progress.mb_downloaded.toFixed(2)} MB
|
{progress.mb_downloaded.toFixed(2)} MB
|
||||||
</p>
|
</p>
|
||||||
{progress.speed_mbps > 0 && (<p className="text-xs text-muted-foreground font-mono tabular-nums">
|
{progress.speed_mbps > 0 && (<p className="text-xs font-mono tabular-nums text-muted-foreground dark:text-blue-300">
|
||||||
{progress.speed_mbps.toFixed(2)} MB/s
|
{progress.speed_mbps.toFixed(2)} MB/s
|
||||||
</p>)}
|
</p>)}
|
||||||
</div>
|
</div>
|
||||||
<ChevronRight className="h-4 w-4 text-muted-foreground ml-1"/>
|
<ChevronRight className="ml-1 h-4 w-4 text-muted-foreground dark:text-blue-300"/>
|
||||||
</div>
|
</div>
|
||||||
</Button>
|
</Button>
|
||||||
</div>);
|
</div>);
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, Pagi
|
|||||||
import { GetDownloadHistory, ClearDownloadHistory, GetPreviewURL, GetFetchHistory, DeleteDownloadHistoryItem, DeleteFetchHistoryItem, ClearFetchHistoryByType } from "../../wailsjs/go/main/App";
|
import { GetDownloadHistory, ClearDownloadHistory, GetPreviewURL, GetFetchHistory, DeleteDownloadHistoryItem, DeleteFetchHistoryItem, ClearFetchHistoryByType } from "../../wailsjs/go/main/App";
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { openExternal } from "@/lib/utils";
|
import { openExternal } from "@/lib/utils";
|
||||||
|
import { SPOTIFY_PREVIEW_VOLUME } from "@/lib/preview";
|
||||||
|
import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons";
|
||||||
const formatDate = (timestamp: number) => {
|
const formatDate = (timestamp: number) => {
|
||||||
const date = new Date(timestamp * 1000);
|
const date = new Date(timestamp * 1000);
|
||||||
const year = date.getFullYear();
|
const year = date.getFullYear();
|
||||||
@@ -30,6 +32,7 @@ interface DownloadHistoryItem {
|
|||||||
quality: string;
|
quality: string;
|
||||||
format: string;
|
format: string;
|
||||||
path: string;
|
path: string;
|
||||||
|
source: string;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
}
|
}
|
||||||
interface FetchHistoryItem {
|
interface FetchHistoryItem {
|
||||||
@@ -62,10 +65,35 @@ export function HistoryPage({ onHistorySelect }: HistoryPageProps) {
|
|||||||
const [fetchSearchQuery, setFetchSearchQuery] = useState("");
|
const [fetchSearchQuery, setFetchSearchQuery] = useState("");
|
||||||
const [fetchCurrentPage, setFetchCurrentPage] = useState(1);
|
const [fetchCurrentPage, setFetchCurrentPage] = useState(1);
|
||||||
const ITEMS_PER_PAGE = 50;
|
const ITEMS_PER_PAGE = 50;
|
||||||
|
const getTrackLink = (spotifyId: string) => {
|
||||||
|
if (spotifyId?.startsWith("tidal_"))
|
||||||
|
return { url: `https://listen.tidal.com/track/${spotifyId.replace("tidal_", "")}`, label: "Open in Tidal" };
|
||||||
|
if (spotifyId?.startsWith("qobuz_"))
|
||||||
|
return { url: `https://www.qobuz.com/track/${spotifyId.replace("qobuz_", "")}`, label: "Open in Qobuz" };
|
||||||
|
if (spotifyId?.startsWith("amazon_"))
|
||||||
|
return { url: `https://music.amazon.com/tracks/${spotifyId.replace("amazon_", "")}`, label: "Open in Amazon Music" };
|
||||||
|
if (spotifyId?.startsWith("deezer_"))
|
||||||
|
return { url: `https://www.deezer.com/track/${spotifyId.replace("deezer_", "")}`, label: "Open in Deezer" };
|
||||||
|
return { url: `https://open.spotify.com/track/${spotifyId}`, label: "Open in Spotify" };
|
||||||
|
};
|
||||||
|
const getSourceIcon = (source: string) => {
|
||||||
|
const s = source?.toLowerCase() || "";
|
||||||
|
if (s.includes("tidal"))
|
||||||
|
return <TidalIcon className="h-4 w-4 object-contain rounded"/>;
|
||||||
|
if (s.includes("qobuz"))
|
||||||
|
return <QobuzIcon className="h-4 w-4 object-contain"/>;
|
||||||
|
if (s.includes("amazon"))
|
||||||
|
return <AmazonIcon className="h-4 w-4 object-contain rounded"/>;
|
||||||
|
if (s.includes("deezer"))
|
||||||
|
return <Music2 className="h-4 w-4"/>;
|
||||||
|
if (s.includes("spotify"))
|
||||||
|
return <Music2 className="h-4 w-4"/>;
|
||||||
|
return <Music2 className="h-4 w-4 opacity-50"/>;
|
||||||
|
};
|
||||||
const fetchDownloadHistory = async () => {
|
const fetchDownloadHistory = async () => {
|
||||||
try {
|
try {
|
||||||
const items = await GetDownloadHistory();
|
const items = await GetDownloadHistory();
|
||||||
setDownloadHistory(items || []);
|
setDownloadHistory((items || []) as unknown as DownloadHistoryItem[]);
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
console.error("Failed to fetch download history:", err);
|
console.error("Failed to fetch download history:", err);
|
||||||
@@ -164,7 +192,7 @@ export function HistoryPage({ onHistorySelect }: HistoryPageProps) {
|
|||||||
if (url) {
|
if (url) {
|
||||||
const audio = new Audio(url);
|
const audio = new Audio(url);
|
||||||
audioRef.current = audio;
|
audioRef.current = audio;
|
||||||
audio.volume = 0.5;
|
audio.volume = SPOTIFY_PREVIEW_VOLUME;
|
||||||
audio.onended = () => setPlayingPreviewId(null);
|
audio.onended = () => setPlayingPreviewId(null);
|
||||||
audio.play();
|
audio.play();
|
||||||
setPlayingPreviewId(id);
|
setPlayingPreviewId(id);
|
||||||
@@ -228,8 +256,8 @@ export function HistoryPage({ onHistorySelect }: HistoryPageProps) {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<h2 className="text-xl font-bold tracking-tight">Downloads</h2>
|
<h2 className="text-xl font-bold tracking-tight">Downloads</h2>
|
||||||
{downloadHistory.length > 0 && (<Badge variant="secondary" className="font-mono">
|
{filteredDownloadHistory.length > 0 && (<Badge variant="secondary" className="font-mono">
|
||||||
{downloadHistory.length.toLocaleString('en-US')}
|
{filteredDownloadHistory.length.toLocaleString('en-US')}
|
||||||
</Badge>)}
|
</Badge>)}
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="sm" onClick={() => setShowClearDownloadConfirm(true)} disabled={downloadHistory.length === 0} className="cursor-pointer gap-2">
|
<Button variant="outline" size="sm" onClick={() => setShowClearDownloadConfirm(true)} disabled={downloadHistory.length === 0} className="cursor-pointer gap-2">
|
||||||
@@ -275,11 +303,12 @@ export function HistoryPage({ onHistorySelect }: HistoryPageProps) {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b bg-muted/50">
|
<tr className="border-b bg-muted/50">
|
||||||
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground w-12 text-xs uppercase">#</th>
|
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground w-12 text-xs uppercase">#</th>
|
||||||
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground text-xs uppercase">Title</th>
|
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground text-xs uppercase w-[35%]">Title</th>
|
||||||
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground hidden md:table-cell text-xs uppercase w-1/4">Album</th>
|
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground hidden md:table-cell text-xs uppercase w-48 lg:w-48 xl:w-56">Album</th>
|
||||||
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground hidden lg:table-cell w-32 text-xs uppercase">Format</th>
|
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground hidden lg:table-cell w-32 text-xs uppercase">Format</th>
|
||||||
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground hidden xl:table-cell w-16 text-xs uppercase text-nowrap">Dur</th>
|
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground hidden xl:table-cell w-16 text-xs uppercase text-nowrap">Dur</th>
|
||||||
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground hidden md:table-cell w-36 text-xs uppercase text-nowrap">Downloaded At</th>
|
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground hidden md:table-cell w-36 text-xs uppercase text-nowrap">Downloaded At</th>
|
||||||
|
<th className="h-10 px-4 text-center align-middle font-medium text-muted-foreground w-16 text-xs uppercase text-nowrap">Source</th>
|
||||||
<th className="h-10 px-4 text-center align-middle font-medium text-muted-foreground w-32 text-xs uppercase text-nowrap">Actions</th>
|
<th className="h-10 px-4 text-center align-middle font-medium text-muted-foreground w-32 text-xs uppercase text-nowrap">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -311,36 +340,52 @@ export function HistoryPage({ onHistorySelect }: HistoryPageProps) {
|
|||||||
<td className="p-3 align-middle text-sm text-muted-foreground text-left hidden xl:table-cell font-mono">
|
<td className="p-3 align-middle text-sm text-muted-foreground text-left hidden xl:table-cell font-mono">
|
||||||
{item.duration_str}
|
{item.duration_str}
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3 align-middle text-xs text-muted-foreground hidden md:table-cell whitespace-nowrap">
|
<td className="p-3 align-middle text-xs text-muted-foreground hidden md:table-cell whitespace-nowrap text-left">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span>{formatDate(item.timestamp).split(' ')[0]}</span>
|
<span>{formatDate(item.timestamp).split(' ')[0]}</span>
|
||||||
<span className="text-[10px] text-muted-foreground">{formatDate(item.timestamp).split(' ')[1]}</span>
|
<span className="text-[10px] text-muted-foreground">{formatDate(item.timestamp).split(' ')[1]}</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3 align-middle text-center">
|
<td className="p-3 align-middle text-center">
|
||||||
<div className="flex items-center justify-center gap-1">
|
<div className="flex items-center justify-center">
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip delayDuration={0}>
|
<Tooltip delayDuration={0}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8 cursor-pointer" onClick={() => handlePreview(item.id, item.spotify_id)} disabled={!item.spotify_id}>
|
<div className="flex items-center justify-center">
|
||||||
{playingPreviewId === item.id ? <Pause className="h-4 w-4"/> : <Play className="h-4 w-4"/>}
|
{getSourceIcon(item.source)}
|
||||||
</Button>
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>{playingPreviewId === item.id ? "Pause Preview" : "Play Preview"}</p>
|
<p className="capitalize">{item.source || "Unknown"}</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="p-3 align-middle text-center">
|
||||||
|
<div className="flex items-center justify-center gap-1">
|
||||||
|
{!(item.spotify_id?.startsWith('tidal_') || item.spotify_id?.startsWith('qobuz_') || item.spotify_id?.startsWith('amazon_') || item.spotify_id?.startsWith('deezer_')) && (<TooltipProvider>
|
||||||
|
<Tooltip delayDuration={0}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8 cursor-pointer" onClick={() => handlePreview(item.id, item.spotify_id)} disabled={!item.spotify_id}>
|
||||||
|
{playingPreviewId === item.id ? <Pause className="h-4 w-4"/> : <Play className="h-4 w-4"/>}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{playingPreviewId === item.id ? "Pause Preview" : "Play Preview"}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>)}
|
||||||
|
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip delayDuration={0}>
|
<Tooltip delayDuration={0}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8 cursor-pointer" onClick={() => openExternal(`https://open.spotify.com/track/${item.spotify_id}`)}>
|
<Button variant="ghost" size="icon" className="h-8 w-8 cursor-pointer" onClick={() => openExternal(getTrackLink(item.spotify_id).url)}>
|
||||||
<ExternalLink className="h-4 w-4"/>
|
<ExternalLink className="h-4 w-4"/>
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>Open in Spotify</p>
|
<p>{getTrackLink(item.spotify_id).label}</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ export function PlaylistInfo({ playlistInfo, trackList, searchQuery, sortBy, sel
|
|||||||
if (response.already_exists)
|
if (response.already_exists)
|
||||||
toast.info("Cover already exists");
|
toast.info("Cover already exists");
|
||||||
else
|
else
|
||||||
toast.success("Playlist cover downloaded");
|
toast.success("Separate playlist cover downloaded");
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
toast.error(response.error || "Failed to download cover");
|
toast.error(response.error || "Failed to download cover");
|
||||||
@@ -165,7 +165,7 @@ export function PlaylistInfo({ playlistInfo, trackList, searchQuery, sortBy, sel
|
|||||||
{downloadingPlaylistCover ? <Spinner /> : <ImageDown className="h-4 w-4"/>}
|
{downloadingPlaylistCover ? <Spinner /> : <ImageDown className="h-4 w-4"/>}
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent><p>Download Playlist Cover</p></TooltipContent>
|
<TooltipContent><p>Download Separate Playlist Cover</p></TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>)}
|
</div>)}
|
||||||
@@ -213,7 +213,7 @@ export function PlaylistInfo({ playlistInfo, trackList, searchQuery, sortBy, sel
|
|||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>Download All Covers</p>
|
<p>Download All Separate Covers</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>)}
|
</Tooltip>)}
|
||||||
{downloadedTracks.size > 0 && (<Button onClick={onOpenFolder} variant="outline">
|
{downloadedTracks.size > 0 && (<Button onClick={onOpenFolder} variant="outline">
|
||||||
|
|||||||
@@ -130,12 +130,14 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
|||||||
<div className="flex items-center justify-between shrink-0">
|
<div className="flex items-center justify-between shrink-0">
|
||||||
<h1 className="text-2xl font-bold">Settings</h1>
|
<h1 className="text-2xl font-bold">Settings</h1>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button variant="outline" onClick={async () => { try {
|
<Button variant="outline" onClick={async () => {
|
||||||
await OpenConfigFolder();
|
try {
|
||||||
}
|
await OpenConfigFolder();
|
||||||
catch (e) {
|
}
|
||||||
toast.error(`Failed to open config folder: ${e}`);
|
catch (e) {
|
||||||
} }} className="gap-1.5">
|
toast.error(`Failed to open config folder: ${e}`);
|
||||||
|
}
|
||||||
|
}} className="gap-1.5">
|
||||||
<FolderLock className="h-4 w-4"/>
|
<FolderLock className="h-4 w-4"/>
|
||||||
Open Config Folder
|
Open Config Folder
|
||||||
</Button>
|
</Button>
|
||||||
@@ -161,7 +163,7 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
|||||||
</Button>
|
</Button>
|
||||||
<Button variant={activeTab === "api" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("api")} className="rounded-b-none gap-2">
|
<Button variant={activeTab === "api" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("api")} className="rounded-b-none gap-2">
|
||||||
<Router className="h-4 w-4"/>
|
<Router className="h-4 w-4"/>
|
||||||
API Status
|
Status
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +1,54 @@
|
|||||||
|
import { useRef, useState, type RefObject } from "react";
|
||||||
import { HomeIcon } from "@/components/ui/home";
|
import { HomeIcon } from "@/components/ui/home";
|
||||||
import { HistoryIcon } from "@/components/ui/history-icon";
|
import { HistoryIcon } from "@/components/ui/history-icon";
|
||||||
import { SettingsIcon } from "@/components/ui/settings";
|
import { SettingsIcon } from "@/components/ui/settings";
|
||||||
import { ActivityIcon } from "@/components/ui/activity";
|
import { ActivityIcon, type ActivityIconHandle } from "@/components/ui/activity";
|
||||||
import { TerminalIcon } from "@/components/ui/terminal";
|
import { TerminalIcon } from "@/components/ui/terminal";
|
||||||
import { FileMusicIcon } from "@/components/ui/file-music";
|
import { FileMusicIcon, type FileMusicIconHandle } from "@/components/ui/file-music";
|
||||||
import { FilePenIcon } from "@/components/ui/file-pen";
|
import { FilePenIcon, type FilePenIconHandle } from "@/components/ui/file-pen";
|
||||||
import { CoffeeIcon } from "@/components/ui/coffee";
|
import { CoffeeIcon } from "@/components/ui/coffee";
|
||||||
import { BadgeAlertIcon } from "@/components/ui/badge-alert";
|
import { BadgeAlertIcon } from "@/components/ui/badge-alert";
|
||||||
import { GithubIcon } from "@/components/ui/github";
|
import { GithubIcon } from "@/components/ui/github";
|
||||||
import { BlocksIcon } from "@/components/ui/blocks-icon";
|
import { BlocksIcon } from "@/components/ui/blocks-icon";
|
||||||
|
import { AudioLinesIcon, type AudioLinesIconHandle } from "@/components/ui/audio-lines";
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { openExternal } from "@/lib/utils";
|
import { openExternal } from "@/lib/utils";
|
||||||
export type PageType = "main" | "settings" | "debug" | "audio-analysis" | "audio-converter" | "file-manager" | "about" | "history";
|
export type PageType = "main" | "settings" | "debug" | "audio-analysis" | "audio-converter" | "audio-resampler" | "file-manager" | "about" | "history";
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
currentPage: PageType;
|
currentPage: PageType;
|
||||||
onPageChange: (page: PageType) => void;
|
onPageChange: (page: PageType) => void;
|
||||||
}
|
}
|
||||||
|
interface AnimatedIconHandle {
|
||||||
|
startAnimation: () => void;
|
||||||
|
stopAnimation: () => void;
|
||||||
|
}
|
||||||
export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
||||||
|
const [isIssuesDialogOpen, setIsIssuesDialogOpen] = useState(false);
|
||||||
|
const [hasIssueAgreement, setHasIssueAgreement] = useState(false);
|
||||||
|
const analyzerIconRef = useRef<ActivityIconHandle>(null);
|
||||||
|
const resamplerIconRef = useRef<AudioLinesIconHandle>(null);
|
||||||
|
const converterIconRef = useRef<FileMusicIconHandle>(null);
|
||||||
|
const fileManagerIconRef = useRef<FilePenIconHandle>(null);
|
||||||
|
const handleIssuesDialogChange = (open: boolean) => {
|
||||||
|
setIsIssuesDialogOpen(open);
|
||||||
|
if (!open) {
|
||||||
|
setHasIssueAgreement(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleOpenIssues = () => {
|
||||||
|
openExternal("https://github.com/afkarxyz/SpotiFLAC/issues");
|
||||||
|
handleIssuesDialogChange(false);
|
||||||
|
};
|
||||||
|
const getAnimatedItemHandlers = <T extends AnimatedIconHandle>(iconRef: RefObject<T | null>) => ({
|
||||||
|
onMouseEnter: () => iconRef.current?.startAnimation(),
|
||||||
|
onMouseLeave: () => iconRef.current?.stopAnimation(),
|
||||||
|
onFocus: () => iconRef.current?.startAnimation(),
|
||||||
|
onBlur: () => iconRef.current?.stopAnimation(),
|
||||||
|
});
|
||||||
return (<div className="fixed left-0 top-0 h-full w-14 bg-card border-r border-border flex flex-col items-center py-14 z-30">
|
return (<div className="fixed left-0 top-0 h-full w-14 bg-card border-r border-border flex flex-col items-center py-14 z-30">
|
||||||
<div className="flex flex-col gap-2 flex-1">
|
<div className="flex flex-col gap-2 flex-1">
|
||||||
<Tooltip delayDuration={0}>
|
<Tooltip delayDuration={0}>
|
||||||
@@ -69,7 +99,7 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
|||||||
<Tooltip delayDuration={0}>
|
<Tooltip delayDuration={0}>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button variant={["audio-analysis", "audio-converter", "file-manager"].includes(currentPage) ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${["audio-analysis", "audio-converter", "file-manager"].includes(currentPage) ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`}>
|
<Button variant={["audio-analysis", "audio-converter", "audio-resampler", "file-manager"].includes(currentPage) ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${["audio-analysis", "audio-converter", "audio-resampler", "file-manager"].includes(currentPage) ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`}>
|
||||||
<BlocksIcon size={20} loop={true}/>
|
<BlocksIcon size={20} loop={true}/>
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
@@ -79,16 +109,20 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
|||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<DropdownMenuContent side="right" sideOffset={14} className="min-w-[200px] ml-2">
|
<DropdownMenuContent side="right" sideOffset={14} className="min-w-[200px] ml-2">
|
||||||
<DropdownMenuItem onClick={() => onPageChange("audio-analysis")} className="gap-3 cursor-pointer py-2 px-3">
|
<DropdownMenuItem onClick={() => onPageChange("audio-analysis")} className="gap-3 cursor-pointer py-2 px-3" {...getAnimatedItemHandlers(analyzerIconRef)}>
|
||||||
<ActivityIcon size={16}/>
|
<ActivityIcon ref={analyzerIconRef} size={16}/>
|
||||||
<span>Audio Quality Analyzer</span>
|
<span>Audio Quality Analyzer</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => onPageChange("audio-converter")} className="gap-3 cursor-pointer py-2 px-3">
|
<DropdownMenuItem onClick={() => onPageChange("audio-resampler")} className="gap-3 cursor-pointer py-2 px-3" {...getAnimatedItemHandlers(resamplerIconRef)}>
|
||||||
<FileMusicIcon size={16}/>
|
<AudioLinesIcon ref={resamplerIconRef} size={16}/>
|
||||||
|
<span>Audio Resampler</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => onPageChange("audio-converter")} className="gap-3 cursor-pointer py-2 px-3" {...getAnimatedItemHandlers(converterIconRef)}>
|
||||||
|
<FileMusicIcon ref={converterIconRef} size={16}/>
|
||||||
<span>Audio Converter</span>
|
<span>Audio Converter</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => onPageChange("file-manager")} className="gap-3 cursor-pointer py-2 px-3">
|
<DropdownMenuItem onClick={() => onPageChange("file-manager")} className="gap-3 cursor-pointer py-2 px-3" {...getAnimatedItemHandlers(fileManagerIconRef)}>
|
||||||
<FilePenIcon size={16}/>
|
<FilePenIcon ref={fileManagerIconRef} size={16}/>
|
||||||
<span>File Manager</span>
|
<span>File Manager</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
@@ -96,16 +130,49 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-auto flex flex-col gap-2">
|
<div className="mt-auto flex flex-col gap-2">
|
||||||
<Tooltip delayDuration={0}>
|
<Dialog open={isIssuesDialogOpen} onOpenChange={handleIssuesDialogChange}>
|
||||||
<TooltipTrigger asChild>
|
<Tooltip delayDuration={0}>
|
||||||
<Button variant="ghost" size="icon" className="h-10 w-10 hover:bg-primary/10 hover:text-primary" onClick={() => openExternal("https://github.com/afkarxyz/SpotiFLAC/issues/268")}>
|
<TooltipTrigger asChild>
|
||||||
<GithubIcon size={20}/>
|
<Button variant="ghost" size="icon" className="h-10 w-10 hover:bg-primary/10 hover:text-primary" onClick={() => setIsIssuesDialogOpen(true)}>
|
||||||
</Button>
|
<GithubIcon size={20}/>
|
||||||
</TooltipTrigger>
|
</Button>
|
||||||
<TooltipContent side="right">
|
</TooltipTrigger>
|
||||||
<p>Report Bugs or Request Features</p>
|
<TooltipContent side="right">
|
||||||
</TooltipContent>
|
<p>Report Bugs or Request Features</p>
|
||||||
</Tooltip>
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<DialogContent className="max-w-xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Before Opening GitHub Issues</DialogTitle>
|
||||||
|
<DialogDescription />
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 text-sm">
|
||||||
|
<div className="rounded-lg border border-amber-500/40 bg-amber-500/10 p-4">
|
||||||
|
<p className="font-semibold text-amber-900 dark:text-amber-200">Important</p>
|
||||||
|
<p className="mt-1 text-amber-950/90 dark:text-amber-100/90">
|
||||||
|
Search existing issues first and use the issue template when opening a new report or request.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex cursor-pointer items-center gap-3 rounded-lg border p-4">
|
||||||
|
<Checkbox className="shrink-0" checked={hasIssueAgreement} onCheckedChange={(checked) => setHasIssueAgreement(checked === true)}/>
|
||||||
|
<span className="leading-5 text-foreground/90">
|
||||||
|
I understand that I should use the issue template and avoid duplicate issues.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="sm:justify-between gap-2">
|
||||||
|
<Button variant="outline" onClick={() => handleIssuesDialogChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button disabled={!hasIssueAgreement} onClick={handleOpenIssues}>
|
||||||
|
Open Issues
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
<Tooltip delayDuration={0}>
|
<Tooltip delayDuration={0}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
|
|||||||
@@ -1,13 +1,463 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef, useState, forwardRef, useImperativeHandle } from "react";
|
||||||
import type { SpectrumData } from "@/types/api";
|
import type { SpectrumData } from "@/types/api";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
import { loadAudioAnalysisPreferences, saveAudioAnalysisPreferences, type AnalyzerColorScheme, type AnalyzerFreqScale, type AnalyzerWindowFunction, } from "@/lib/audio-analysis-preferences";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select";
|
||||||
|
export interface SpectrumVisualizationHandle {
|
||||||
|
getCanvasDataURL: () => string | null;
|
||||||
|
}
|
||||||
interface SpectrumVisualizationProps {
|
interface SpectrumVisualizationProps {
|
||||||
sampleRate: number;
|
sampleRate: number;
|
||||||
bitsPerSample: number;
|
|
||||||
duration: number;
|
duration: number;
|
||||||
spectrumData?: SpectrumData;
|
spectrumData?: SpectrumData;
|
||||||
|
fileName?: string;
|
||||||
|
onReAnalyze?: (fftSize: number, windowFunction: string) => void;
|
||||||
|
isAnalyzingSpectrum?: boolean;
|
||||||
|
spectrumProgress?: {
|
||||||
|
percent: number;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
export function SpectrumVisualization({ sampleRate, bitsPerSample, duration, spectrumData, }: SpectrumVisualizationProps) {
|
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;
|
||||||
|
const MAX_RENDER_HEIGHT = 1080;
|
||||||
|
function clamp01(value: number): number {
|
||||||
|
return Math.max(0, Math.min(1, value));
|
||||||
|
}
|
||||||
|
function spekColorMap(t: number): [
|
||||||
|
number,
|
||||||
|
number,
|
||||||
|
number
|
||||||
|
] {
|
||||||
|
const colors: Array<[
|
||||||
|
number,
|
||||||
|
number,
|
||||||
|
number
|
||||||
|
]> = [
|
||||||
|
[0, 0, 0],
|
||||||
|
[0, 0, 25],
|
||||||
|
[0, 0, 50],
|
||||||
|
[0, 0, 80],
|
||||||
|
[20, 0, 120],
|
||||||
|
[50, 0, 150],
|
||||||
|
[80, 0, 180],
|
||||||
|
[120, 0, 120],
|
||||||
|
[150, 0, 80],
|
||||||
|
[180, 0, 40],
|
||||||
|
[210, 0, 0],
|
||||||
|
[240, 30, 0],
|
||||||
|
[255, 60, 0],
|
||||||
|
[255, 100, 0],
|
||||||
|
[255, 140, 0],
|
||||||
|
[255, 180, 0],
|
||||||
|
[255, 210, 0],
|
||||||
|
[255, 235, 0],
|
||||||
|
[255, 250, 50],
|
||||||
|
[255, 255, 100],
|
||||||
|
[255, 255, 150],
|
||||||
|
[255, 255, 200],
|
||||||
|
[255, 255, 255],
|
||||||
|
];
|
||||||
|
const scaled = t * (colors.length - 1);
|
||||||
|
const idx = Math.floor(scaled);
|
||||||
|
const fraction = scaled - idx;
|
||||||
|
if (idx >= colors.length - 1) {
|
||||||
|
return colors[colors.length - 1];
|
||||||
|
}
|
||||||
|
const c1 = colors[idx];
|
||||||
|
const c2 = colors[idx + 1];
|
||||||
|
return [
|
||||||
|
Math.round(c1[0] + (c2[0] - c1[0]) * fraction),
|
||||||
|
Math.round(c1[1] + (c2[1] - c1[1]) * fraction),
|
||||||
|
Math.round(c1[2] + (c2[2] - c1[2]) * fraction),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
function viridisColorMap(t: number): [
|
||||||
|
number,
|
||||||
|
number,
|
||||||
|
number
|
||||||
|
] {
|
||||||
|
const colors: Array<[
|
||||||
|
number,
|
||||||
|
number,
|
||||||
|
number
|
||||||
|
]> = [
|
||||||
|
[68, 1, 84],
|
||||||
|
[70, 20, 100],
|
||||||
|
[72, 40, 120],
|
||||||
|
[67, 62, 133],
|
||||||
|
[62, 74, 137],
|
||||||
|
[55, 89, 140],
|
||||||
|
[49, 104, 142],
|
||||||
|
[43, 117, 142],
|
||||||
|
[38, 130, 142],
|
||||||
|
[35, 144, 140],
|
||||||
|
[31, 158, 137],
|
||||||
|
[42, 171, 129],
|
||||||
|
[53, 183, 121],
|
||||||
|
[81, 194, 105],
|
||||||
|
[109, 205, 89],
|
||||||
|
[144, 214, 67],
|
||||||
|
[180, 222, 44],
|
||||||
|
[216, 227, 41],
|
||||||
|
[253, 231, 37],
|
||||||
|
];
|
||||||
|
const scaled = t * (colors.length - 1);
|
||||||
|
const idx = Math.floor(scaled);
|
||||||
|
const fraction = scaled - idx;
|
||||||
|
if (idx >= colors.length - 1) {
|
||||||
|
return colors[colors.length - 1];
|
||||||
|
}
|
||||||
|
const c1 = colors[idx];
|
||||||
|
const c2 = colors[idx + 1];
|
||||||
|
return [
|
||||||
|
Math.floor(c1[0] + (c2[0] - c1[0]) * fraction),
|
||||||
|
Math.floor(c1[1] + (c2[1] - c1[1]) * fraction),
|
||||||
|
Math.floor(c1[2] + (c2[2] - c1[2]) * fraction),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
function hotColorMap(t: number): [
|
||||||
|
number,
|
||||||
|
number,
|
||||||
|
number
|
||||||
|
] {
|
||||||
|
if (t < 0.33) {
|
||||||
|
return [Math.floor(t * 3 * 255), 0, 0];
|
||||||
|
}
|
||||||
|
if (t < 0.66) {
|
||||||
|
return [255, Math.floor((t - 0.33) * 3 * 255), 0];
|
||||||
|
}
|
||||||
|
return [255, 255, Math.floor((t - 0.66) * 3 * 255)];
|
||||||
|
}
|
||||||
|
function coolColorMap(t: number): [
|
||||||
|
number,
|
||||||
|
number,
|
||||||
|
number
|
||||||
|
] {
|
||||||
|
return [Math.floor(t * 255), Math.floor((1 - t) * 255), 255];
|
||||||
|
}
|
||||||
|
function getColorValues(norm: number, scheme: ColorScheme): [
|
||||||
|
number,
|
||||||
|
number,
|
||||||
|
number
|
||||||
|
] {
|
||||||
|
const value = clamp01(norm);
|
||||||
|
switch (scheme) {
|
||||||
|
case "spek":
|
||||||
|
return spekColorMap(value);
|
||||||
|
case "viridis":
|
||||||
|
return viridisColorMap(value);
|
||||||
|
case "hot":
|
||||||
|
return hotColorMap(value);
|
||||||
|
case "cool":
|
||||||
|
return coolColorMap(value);
|
||||||
|
case "grayscale":
|
||||||
|
default: {
|
||||||
|
const gray = Math.floor(value * 255);
|
||||||
|
return [gray, gray, gray];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function getColorString(norm: number, scheme: ColorScheme): string {
|
||||||
|
const [r, g, b] = getColorValues(norm, scheme);
|
||||||
|
return `rgb(${r},${g},${b})`;
|
||||||
|
}
|
||||||
|
function addAxisLabels(ctx: CanvasRenderingContext2D, plotWidth: number, plotHeight: number, sampleRate: number, duration: number, freqScale: FreqScale, fileName?: string) {
|
||||||
|
ctx.fillStyle = "#ffffff";
|
||||||
|
ctx.font = "12px Segoe UI";
|
||||||
|
ctx.textAlign = "center";
|
||||||
|
const widthFactor = plotWidth / 1000;
|
||||||
|
let timeStep: number;
|
||||||
|
if (duration <= 10) {
|
||||||
|
timeStep = widthFactor >= 1.8 ? 0.25 : (widthFactor >= 1.3 ? 0.5 : 0.5);
|
||||||
|
}
|
||||||
|
else if (duration <= 30) {
|
||||||
|
timeStep = widthFactor >= 1.8 ? 0.5 : (widthFactor >= 1.3 ? 1 : 1);
|
||||||
|
}
|
||||||
|
else if (duration <= 120) {
|
||||||
|
timeStep = widthFactor >= 1.8 ? 3 : (widthFactor >= 1.3 ? 4 : 5);
|
||||||
|
}
|
||||||
|
else if (duration <= 600) {
|
||||||
|
timeStep = widthFactor >= 1.8 ? 10 : (widthFactor >= 1.3 ? 15 : 20);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
timeStep = widthFactor >= 1.8 ? 20 : (widthFactor >= 1.3 ? 30 : 40);
|
||||||
|
}
|
||||||
|
if (duration > 0) {
|
||||||
|
for (let time = 0; time <= duration + 1e-9; time += timeStep) {
|
||||||
|
const timeProgress = time / duration;
|
||||||
|
const x = MARGIN.left + timeProgress * (plotWidth - 1);
|
||||||
|
const y = CANVAS_H - MARGIN.bottom + 20;
|
||||||
|
ctx.strokeStyle = "#ffffff";
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x, MARGIN.top + plotHeight);
|
||||||
|
ctx.lineTo(x, MARGIN.top + plotHeight + 5);
|
||||||
|
ctx.stroke();
|
||||||
|
let label: string;
|
||||||
|
if (timeStep >= 60) {
|
||||||
|
const minutes = Math.floor(time / 60);
|
||||||
|
const seconds = time % 60;
|
||||||
|
label = seconds === 0 ? `${minutes}m` : `${minutes}m${seconds}s`;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
label = `${time}s`;
|
||||||
|
}
|
||||||
|
ctx.fillText(label, x, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.textAlign = "right";
|
||||||
|
const maxFreq = sampleRate / 2;
|
||||||
|
if (freqScale === "log2") {
|
||||||
|
const heightFactor = plotHeight / 500;
|
||||||
|
const minFreq = 20;
|
||||||
|
const frequencies: number[] = [];
|
||||||
|
const octaveStep = heightFactor >= 1.5 ? 1 : (heightFactor >= 1.0 ? 1 : 2);
|
||||||
|
let octaveCount = 0;
|
||||||
|
for (let freq = minFreq; freq <= maxFreq; freq *= 2) {
|
||||||
|
if (octaveCount % octaveStep === 0) {
|
||||||
|
frequencies.push(freq);
|
||||||
|
}
|
||||||
|
octaveCount++;
|
||||||
|
}
|
||||||
|
for (const freq of frequencies) {
|
||||||
|
const freqNormalized = Math.log2(freq / minFreq) / Math.log2(maxFreq / minFreq);
|
||||||
|
const y = MARGIN.top + plotHeight * (1 - freqNormalized);
|
||||||
|
ctx.strokeStyle = "#ffffff";
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(MARGIN.left - 5, y);
|
||||||
|
ctx.lineTo(MARGIN.left, y);
|
||||||
|
ctx.stroke();
|
||||||
|
const label = freq >= 1000 ? `${(freq / 1000).toFixed(1)}k` : `${freq}`;
|
||||||
|
ctx.fillText(label, MARGIN.left - 10, y + 4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
const heightFactor = plotHeight / 500;
|
||||||
|
let freqStep: number;
|
||||||
|
if (maxFreq <= 8000) {
|
||||||
|
freqStep = heightFactor >= 1.8 ? 250 : (heightFactor >= 1.3 ? 400 : 500);
|
||||||
|
}
|
||||||
|
else if (maxFreq <= 16000) {
|
||||||
|
freqStep = heightFactor >= 1.8 ? 500 : (heightFactor >= 1.3 ? 800 : 1000);
|
||||||
|
}
|
||||||
|
else if (maxFreq <= 24000) {
|
||||||
|
freqStep = heightFactor >= 1.8 ? 1000 : (heightFactor >= 1.3 ? 1500 : 2000);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
freqStep = heightFactor >= 1.8 ? 2000 : (heightFactor >= 1.3 ? 2500 : 4000);
|
||||||
|
}
|
||||||
|
for (let freq = 0; freq <= maxFreq; freq += freqStep) {
|
||||||
|
const y = MARGIN.top + plotHeight - (freq / maxFreq) * plotHeight + 4;
|
||||||
|
const x = MARGIN.left - 15;
|
||||||
|
ctx.strokeStyle = "#ffffff";
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(MARGIN.left - 5, y - 4);
|
||||||
|
ctx.lineTo(MARGIN.left, y - 4);
|
||||||
|
ctx.stroke();
|
||||||
|
let label: string;
|
||||||
|
if (freq === 0) {
|
||||||
|
label = "0";
|
||||||
|
}
|
||||||
|
else if (freq >= 1000) {
|
||||||
|
label = freq % 1000 === 0 ? `${freq / 1000}k` : `${(freq / 1000).toFixed(1)}k`;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
label = `${freq}`;
|
||||||
|
}
|
||||||
|
ctx.fillText(label, x, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.textAlign = "center";
|
||||||
|
ctx.font = "14px Segoe UI";
|
||||||
|
ctx.fillText("Time (seconds)", CANVAS_W / 2, CANVAS_H - 15);
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(25, CANVAS_H / 2);
|
||||||
|
ctx.rotate(-Math.PI / 2);
|
||||||
|
ctx.fillText("Frequency (Hz)", 0, 0);
|
||||||
|
ctx.restore();
|
||||||
|
ctx.font = "12px Segoe UI";
|
||||||
|
if (fileName) {
|
||||||
|
ctx.textAlign = "left";
|
||||||
|
ctx.fillText(fileName, MARGIN.left + 15, 25);
|
||||||
|
}
|
||||||
|
ctx.textAlign = "right";
|
||||||
|
ctx.fillText(`Sample Rate: ${sampleRate} Hz`, CANVAS_W - 20, 25);
|
||||||
|
}
|
||||||
|
function drawColorBar(ctx: CanvasRenderingContext2D, plotHeight: number, colorScheme: ColorScheme) {
|
||||||
|
const colorBarWidth = 20;
|
||||||
|
const colorBarX = CANVAS_W - MARGIN.right + 30;
|
||||||
|
const colorBarY = MARGIN.top;
|
||||||
|
const gradient = ctx.createLinearGradient(0, colorBarY + plotHeight, 0, colorBarY);
|
||||||
|
for (let i = 0; i <= 100; i++) {
|
||||||
|
const value = i / 100;
|
||||||
|
gradient.addColorStop(value, getColorString(value, colorScheme));
|
||||||
|
}
|
||||||
|
ctx.fillStyle = gradient;
|
||||||
|
ctx.fillRect(colorBarX, colorBarY, colorBarWidth, plotHeight);
|
||||||
|
ctx.strokeStyle = "#ffffff";
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.strokeRect(colorBarX, colorBarY, colorBarWidth, plotHeight);
|
||||||
|
ctx.fillStyle = "#ffffff";
|
||||||
|
ctx.font = "10px Segoe UI";
|
||||||
|
ctx.textAlign = "left";
|
||||||
|
ctx.fillText("High", colorBarX + colorBarWidth + 5, colorBarY + 12);
|
||||||
|
ctx.fillText("Low", colorBarX + colorBarWidth + 5, colorBarY + plotHeight - 5);
|
||||||
|
}
|
||||||
|
async function renderSpectrogram(ctx: CanvasRenderingContext2D, spectrum: SpectrumData, sampleRate: number, duration: number, freqScale: FreqScale, colorScheme: ColorScheme, fileName: string | undefined, shouldCancel: () => boolean) {
|
||||||
|
const plotWidth = CANVAS_W - MARGIN.left - MARGIN.right;
|
||||||
|
const plotHeight = CANVAS_H - MARGIN.top - MARGIN.bottom;
|
||||||
|
ctx.fillStyle = "#000000";
|
||||||
|
ctx.fillRect(0, 0, CANVAS_W, CANVAS_H);
|
||||||
|
const spectrogramData = spectrum.time_slices;
|
||||||
|
const numTimeFrames = spectrogramData.length;
|
||||||
|
const numFreqBins = spectrogramData[0]?.magnitudes.length ?? 0;
|
||||||
|
if (numTimeFrames === 0 || numFreqBins === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let minMag = Number.POSITIVE_INFINITY;
|
||||||
|
let maxMag = Number.NEGATIVE_INFINITY;
|
||||||
|
const sampleStep = numTimeFrames > 10000 ? Math.floor(numTimeFrames / 5000) : 1;
|
||||||
|
for (let i = 0; i < numTimeFrames; i += sampleStep) {
|
||||||
|
const frame = spectrogramData[i].magnitudes;
|
||||||
|
for (const mag of frame) {
|
||||||
|
if (Number.isFinite(mag)) {
|
||||||
|
if (mag < minMag)
|
||||||
|
minMag = mag;
|
||||||
|
if (mag > maxMag)
|
||||||
|
maxMag = mag;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(minMag) || !Number.isFinite(maxMag)) {
|
||||||
|
minMag = -120;
|
||||||
|
maxMag = 0;
|
||||||
|
}
|
||||||
|
const magRange = maxMag - minMag;
|
||||||
|
const safeMagRange = magRange > 0 ? magRange : 1;
|
||||||
|
const highResImageData = ctx.createImageData(plotWidth, MAX_RENDER_HEIGHT);
|
||||||
|
const highResData = highResImageData.data;
|
||||||
|
const CHUNK_SIZE = 50;
|
||||||
|
for (let xStart = 0; xStart < plotWidth; xStart += CHUNK_SIZE) {
|
||||||
|
if (shouldCancel()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const xEnd = Math.min(xStart + CHUNK_SIZE, plotWidth);
|
||||||
|
for (let x = xStart; x < xEnd; x++) {
|
||||||
|
const timeProgress = x / (plotWidth - 1);
|
||||||
|
const exactTimePos = timeProgress * (numTimeFrames - 1);
|
||||||
|
const timeIdx = Math.floor(exactTimePos);
|
||||||
|
const timeIdx2 = Math.min(timeIdx + 1, numTimeFrames - 1);
|
||||||
|
const timeFrac = exactTimePos - timeIdx;
|
||||||
|
const frame1 = spectrogramData[timeIdx]?.magnitudes ?? spectrogramData[0].magnitudes;
|
||||||
|
const frame2 = spectrogramData[timeIdx2]?.magnitudes ?? frame1;
|
||||||
|
for (let y = 0; y < MAX_RENDER_HEIGHT; y++) {
|
||||||
|
let freqProgress = (MAX_RENDER_HEIGHT - 1 - y) / (MAX_RENDER_HEIGHT - 1);
|
||||||
|
if (freqScale === "log2") {
|
||||||
|
const minFreq = 20;
|
||||||
|
const maxFreq = sampleRate / 2;
|
||||||
|
const octaves = Math.log2(maxFreq / minFreq);
|
||||||
|
const octave = freqProgress * octaves;
|
||||||
|
const freq = minFreq * Math.pow(2, octave);
|
||||||
|
freqProgress = freq / maxFreq;
|
||||||
|
}
|
||||||
|
const exactFreqPos = freqProgress * (numFreqBins - 1);
|
||||||
|
const freqIdx = Math.floor(exactFreqPos);
|
||||||
|
const freqIdx2 = Math.min(freqIdx + 1, numFreqBins - 1);
|
||||||
|
const freqFrac = exactFreqPos - freqIdx;
|
||||||
|
let magnitude: number;
|
||||||
|
if (timeFrac === 0 && freqFrac === 0) {
|
||||||
|
magnitude = frame1[freqIdx] ?? 0;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
const mag11 = frame1[freqIdx] ?? 0;
|
||||||
|
const mag12 = frame1[freqIdx2] ?? 0;
|
||||||
|
const mag21 = frame2[freqIdx] ?? 0;
|
||||||
|
const mag22 = frame2[freqIdx2] ?? 0;
|
||||||
|
const magT1 = mag11 * (1 - freqFrac) + mag12 * freqFrac;
|
||||||
|
const magT2 = mag21 * (1 - freqFrac) + mag22 * freqFrac;
|
||||||
|
magnitude = magT1 * (1 - timeFrac) + magT2 * timeFrac;
|
||||||
|
}
|
||||||
|
const normalizedMag = clamp01((magnitude - minMag) / safeMagRange);
|
||||||
|
const [r, g, b] = getColorValues(normalizedMag, colorScheme);
|
||||||
|
const pixelIdx = (y * plotWidth + x) * 4;
|
||||||
|
highResData[pixelIdx] = r;
|
||||||
|
highResData[pixelIdx + 1] = g;
|
||||||
|
highResData[pixelIdx + 2] = b;
|
||||||
|
highResData[pixelIdx + 3] = 255;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (xStart + CHUNK_SIZE < plotWidth) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (shouldCancel()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const finalImageData = ctx.createImageData(plotWidth, plotHeight);
|
||||||
|
const finalData = finalImageData.data;
|
||||||
|
for (let y = 0; y < plotHeight; y++) {
|
||||||
|
for (let x = 0; x < plotWidth; x++) {
|
||||||
|
const highResY = Math.round((y / plotHeight) * MAX_RENDER_HEIGHT);
|
||||||
|
const highResIdx = (highResY * plotWidth + x) * 4;
|
||||||
|
const finalIdx = (y * plotWidth + x) * 4;
|
||||||
|
if (highResIdx < highResData.length) {
|
||||||
|
finalData[finalIdx] = highResData[highResIdx];
|
||||||
|
finalData[finalIdx + 1] = highResData[highResIdx + 1];
|
||||||
|
finalData[finalIdx + 2] = highResData[highResIdx + 2];
|
||||||
|
finalData[finalIdx + 3] = highResData[highResIdx + 3];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.putImageData(finalImageData, MARGIN.left, MARGIN.top);
|
||||||
|
addAxisLabels(ctx, plotWidth, plotHeight, sampleRate, duration, freqScale, fileName);
|
||||||
|
drawColorBar(ctx, plotHeight, colorScheme);
|
||||||
|
}
|
||||||
|
const COLOR_SCHEMES: {
|
||||||
|
value: ColorScheme;
|
||||||
|
label: string;
|
||||||
|
gradient: string;
|
||||||
|
}[] = [
|
||||||
|
{ value: "spek", label: "Spek", gradient: "linear-gradient(to right, #0f0040, #1e0080, #4000ff, #8000ff, #ff0080, #ff4000, #ff8000, #ffff00)" },
|
||||||
|
{ value: "viridis", label: "Viridis", gradient: "linear-gradient(to right, #440154, #31688e, #35b779, #fde725)" },
|
||||||
|
{ value: "hot", label: "Hot", gradient: "linear-gradient(to right, #000000, #ff0000, #ffff00, #ffffff)" },
|
||||||
|
{ value: "cool", label: "Cool", gradient: "linear-gradient(to right, #000080, #0000ff, #00ffff, #ffffff)" },
|
||||||
|
{ value: "grayscale", label: "Grayscale", gradient: "linear-gradient(to right, #000000, #ffffff)" },
|
||||||
|
];
|
||||||
|
export const SpectrumVisualization = forwardRef<SpectrumVisualizationHandle, SpectrumVisualizationProps>(({ sampleRate, duration, spectrumData, fileName, onReAnalyze, isAnalyzingSpectrum, spectrumProgress, }, ref) => {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const preferencesRef = useRef(loadAudioAnalysisPreferences());
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
getCanvasDataURL: () => {
|
||||||
|
if (!canvasRef.current)
|
||||||
|
return null;
|
||||||
|
return canvasRef.current.toDataURL("image/png");
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
const [freqScale, setFreqScale] = useState<FreqScale>(preferencesRef.current.freqScale);
|
||||||
|
const [colorScheme, setColorScheme] = useState<ColorScheme>(preferencesRef.current.colorScheme);
|
||||||
|
const [fftSize, setFftSize] = useState<string>(() => String(preferencesRef.current.fftSize));
|
||||||
|
const [windowFunction, setWindowFunction] = useState<WindowFunction>(preferencesRef.current.windowFunction);
|
||||||
|
useEffect(() => {
|
||||||
|
if (spectrumData?.freq_bins) {
|
||||||
|
setFftSize(String((spectrumData.freq_bins - 1) * 2));
|
||||||
|
}
|
||||||
|
}, [spectrumData]);
|
||||||
|
useEffect(() => {
|
||||||
|
saveAudioAnalysisPreferences({
|
||||||
|
colorScheme,
|
||||||
|
freqScale,
|
||||||
|
fftSize: Number(fftSize),
|
||||||
|
windowFunction,
|
||||||
|
});
|
||||||
|
}, [colorScheme, freqScale, fftSize, windowFunction]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
if (!canvas)
|
if (!canvas)
|
||||||
@@ -15,179 +465,107 @@ export function SpectrumVisualization({ sampleRate, bitsPerSample, duration, spe
|
|||||||
const ctx = canvas.getContext("2d");
|
const ctx = canvas.getContext("2d");
|
||||||
if (!ctx)
|
if (!ctx)
|
||||||
return;
|
return;
|
||||||
const width = canvas.width;
|
let canceled = false;
|
||||||
const height = canvas.height;
|
const shouldCancel = () => canceled;
|
||||||
const marginLeft = 70;
|
|
||||||
const marginRight = 70;
|
|
||||||
const marginTop = 30;
|
|
||||||
const marginBottom = 65;
|
|
||||||
const plotWidth = width - marginLeft - marginRight;
|
|
||||||
const plotHeight = height - marginTop - marginBottom;
|
|
||||||
ctx.fillStyle = "#000000";
|
|
||||||
ctx.fillRect(0, 0, width, height);
|
|
||||||
const nyquistFreq = sampleRate / 2;
|
|
||||||
if (spectrumData) {
|
if (spectrumData) {
|
||||||
drawRealSpectrum(ctx, marginLeft, marginTop, plotWidth, plotHeight, spectrumData);
|
void renderSpectrogram(ctx, spectrumData, sampleRate, duration, freqScale, colorScheme, fileName, shouldCancel);
|
||||||
}
|
|
||||||
drawAxesAndLabels(ctx, marginLeft, marginTop, plotWidth, plotHeight, nyquistFreq, duration, sampleRate);
|
|
||||||
drawColorBar(ctx, marginLeft + plotWidth + 15, marginTop, 20, plotHeight);
|
|
||||||
}, [sampleRate, bitsPerSample, duration, spectrumData]);
|
|
||||||
const drawRealSpectrum = (ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, spectrum: SpectrumData) => {
|
|
||||||
const timeSlices = spectrum.time_slices;
|
|
||||||
if (timeSlices.length === 0)
|
|
||||||
return;
|
|
||||||
const freqBins = timeSlices[0].magnitudes.length;
|
|
||||||
const nyquistFreq = spectrum.max_freq;
|
|
||||||
let minDB = 0;
|
|
||||||
let maxDB = -200;
|
|
||||||
timeSlices.forEach((slice) => {
|
|
||||||
slice.magnitudes.forEach((db) => {
|
|
||||||
if (db > maxDB)
|
|
||||||
maxDB = db;
|
|
||||||
if (db < minDB && db > -200)
|
|
||||||
minDB = db;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
minDB = Math.max(minDB, maxDB - 90);
|
|
||||||
const dbRange = maxDB - minDB;
|
|
||||||
const sliceWidth = Math.ceil(width / timeSlices.length);
|
|
||||||
for (let t = 0; t < timeSlices.length; t++) {
|
|
||||||
const slice = timeSlices[t];
|
|
||||||
const xPos = x + (t / timeSlices.length) * width;
|
|
||||||
for (let f = 0; f < freqBins && f < slice.magnitudes.length; f++) {
|
|
||||||
const db = slice.magnitudes[f];
|
|
||||||
const freq = (f / freqBins) * nyquistFreq;
|
|
||||||
const freqRatio = freq / nyquistFreq;
|
|
||||||
const yPos = y + height - (freqRatio * height);
|
|
||||||
const nextFreq = ((f + 1) / freqBins) * nyquistFreq;
|
|
||||||
const nextFreqRatio = nextFreq / nyquistFreq;
|
|
||||||
const nextYPos = y + height - (nextFreqRatio * height);
|
|
||||||
const binHeight = Math.max(1, Math.abs(yPos - nextYPos) + 1);
|
|
||||||
const intensity = Math.max(0, Math.min(1, (db - minDB) / dbRange));
|
|
||||||
const color = getSpekColor(intensity);
|
|
||||||
ctx.fillStyle = color;
|
|
||||||
ctx.fillRect(xPos, nextYPos, sliceWidth, binHeight);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const getSpekColor = (intensity: number): string => {
|
|
||||||
if (intensity < 0.08) {
|
|
||||||
const t = intensity / 0.08;
|
|
||||||
return `rgb(0, 0, ${Math.floor(t * 80)})`;
|
|
||||||
}
|
|
||||||
else if (intensity < 0.18) {
|
|
||||||
const t = (intensity - 0.08) / 0.10;
|
|
||||||
return `rgb(${Math.floor(t * 50)}, ${Math.floor(t * 30)}, ${Math.floor(80 + t * 175)})`;
|
|
||||||
}
|
|
||||||
else if (intensity < 0.28) {
|
|
||||||
const t = (intensity - 0.18) / 0.10;
|
|
||||||
return `rgb(${Math.floor(50 + t * 150)}, ${Math.floor(30 - t * 30)}, ${Math.floor(255 - t * 55)})`;
|
|
||||||
}
|
|
||||||
else if (intensity < 0.40) {
|
|
||||||
const t = (intensity - 0.28) / 0.12;
|
|
||||||
return `rgb(${Math.floor(200 + t * 55)}, 0, ${Math.floor(200 - t * 200)})`;
|
|
||||||
}
|
|
||||||
else if (intensity < 0.52) {
|
|
||||||
const t = (intensity - 0.40) / 0.12;
|
|
||||||
return `rgb(255, ${Math.floor(t * 100)}, 0)`;
|
|
||||||
}
|
|
||||||
else if (intensity < 0.65) {
|
|
||||||
const t = (intensity - 0.52) / 0.13;
|
|
||||||
return `rgb(255, ${Math.floor(100 + t * 80)}, 0)`;
|
|
||||||
}
|
|
||||||
else if (intensity < 0.78) {
|
|
||||||
const t = (intensity - 0.65) / 0.13;
|
|
||||||
return `rgb(255, ${Math.floor(180 + t * 55)}, ${Math.floor(t * 30)})`;
|
|
||||||
}
|
|
||||||
else if (intensity < 0.90) {
|
|
||||||
const t = (intensity - 0.78) / 0.12;
|
|
||||||
return `rgb(255, ${Math.floor(235 + t * 20)}, ${Math.floor(30 + t * 100)})`;
|
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
const t = (intensity - 0.90) / 0.10;
|
ctx.fillStyle = "#000000";
|
||||||
return `rgb(255, 255, ${Math.floor(130 + t * 125)})`;
|
ctx.fillRect(0, 0, CANVAS_W, CANVAS_H);
|
||||||
|
ctx.fillStyle = "#444444";
|
||||||
|
ctx.font = "16px Arial";
|
||||||
|
ctx.textAlign = "center";
|
||||||
|
ctx.fillText("No spectrum data", CANVAS_W / 2, CANVAS_H / 2);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
canceled = true;
|
||||||
|
};
|
||||||
|
}, [spectrumData, sampleRate, duration, freqScale, colorScheme, fileName]);
|
||||||
|
const handleReAnalyze = (newFftSize: string, newWindowFunc: string) => {
|
||||||
|
setFftSize(newFftSize);
|
||||||
|
setWindowFunction(newWindowFunc as WindowFunction);
|
||||||
|
if (onReAnalyze) {
|
||||||
|
onReAnalyze(parseInt(newFftSize, 10), newWindowFunc);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const drawAxesAndLabels = (ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, nyquistFreq: number, duration: number, sampleRate: number) => {
|
const spectrumPercent = Math.round(Math.max(0, Math.min(100, spectrumProgress?.percent ?? 0)));
|
||||||
ctx.fillStyle = "#CCCCCC";
|
return (<div className="space-y-4">
|
||||||
ctx.font = "12px Arial";
|
<div className="flex flex-wrap items-center gap-3 sm:gap-4 p-1">
|
||||||
ctx.textAlign = "right";
|
<div className="flex items-center gap-2">
|
||||||
ctx.textBaseline = "middle";
|
<Label className="whitespace-nowrap text-sm font-medium">Color Scheme:</Label>
|
||||||
const freqLabels = generateFreqLabels(nyquistFreq);
|
<Select value={colorScheme} onValueChange={(v) => setColorScheme(v as ColorScheme)} disabled={isAnalyzingSpectrum}>
|
||||||
freqLabels.forEach(freq => {
|
<SelectTrigger className="h-8 w-[130px] text-sm">
|
||||||
if (freq <= nyquistFreq) {
|
<SelectValue />
|
||||||
const freqRatio = freq / nyquistFreq;
|
</SelectTrigger>
|
||||||
const yPos = y + height - (freqRatio * height);
|
<SelectContent>
|
||||||
const label = freq >= 1000 ? `${freq / 1000}k` : `${freq}`;
|
{COLOR_SCHEMES.map((scheme) => (<SelectItem key={scheme.value} value={scheme.value}>
|
||||||
ctx.fillText(label, x - 8, yPos);
|
<div className="flex items-center gap-2">
|
||||||
}
|
<div className="h-4 w-4 rounded-sm border opacity-90" style={{ backgroundImage: scheme.gradient }}/>
|
||||||
});
|
<span>{scheme.label}</span>
|
||||||
ctx.fillText("0", x - 8, y + height);
|
</div>
|
||||||
ctx.textAlign = "center";
|
</SelectItem>))}
|
||||||
ctx.textBaseline = "top";
|
</SelectContent>
|
||||||
const timeStep = getTimeStep(duration);
|
</Select>
|
||||||
for (let t = 0; t <= duration; t += timeStep) {
|
</div>
|
||||||
const xPos = x + (t / duration) * width;
|
|
||||||
ctx.fillText(`${Math.round(t)}s`, xPos, y + height + 8);
|
<div className="h-6 w-px bg-border hidden sm:block mx-1"></div>
|
||||||
}
|
|
||||||
ctx.fillStyle = "#FFFFFF";
|
<div className="flex items-center gap-2">
|
||||||
ctx.font = "13px Arial";
|
<Label className="whitespace-nowrap text-sm font-medium">Freq Scale:</Label>
|
||||||
ctx.save();
|
<Select value={freqScale} onValueChange={(v) => setFreqScale(v as FreqScale)} disabled={isAnalyzingSpectrum}>
|
||||||
ctx.translate(12, y + height / 2);
|
<SelectTrigger className="h-8 w-[95px] text-sm">
|
||||||
ctx.rotate(-Math.PI / 2);
|
<SelectValue />
|
||||||
ctx.textAlign = "center";
|
</SelectTrigger>
|
||||||
ctx.fillText("Frequency (Hz)", 0, 0);
|
<SelectContent>
|
||||||
ctx.restore();
|
<SelectItem value="linear">Linear</SelectItem>
|
||||||
ctx.textAlign = "center";
|
<SelectItem value="log2">Log2</SelectItem>
|
||||||
ctx.fillText("Time (seconds)", x + width / 2, y + height + 35);
|
</SelectContent>
|
||||||
ctx.textAlign = "right";
|
</Select>
|
||||||
ctx.fillStyle = "#CCCCCC";
|
</div>
|
||||||
ctx.font = "12px Arial";
|
|
||||||
ctx.fillText(`Sample Rate: ${sampleRate} Hz`, x + width - 5, y - 3);
|
<div className="flex items-center gap-2">
|
||||||
};
|
<Label className="whitespace-nowrap text-sm font-medium">FFT Size:</Label>
|
||||||
const generateFreqLabels = (nyquistFreq: number): number[] => {
|
<Select value={fftSize} onValueChange={(v) => handleReAnalyze(v, windowFunction)} disabled={isAnalyzingSpectrum}>
|
||||||
if (nyquistFreq <= 24000) {
|
<SelectTrigger className="h-8 w-[90px] text-sm">
|
||||||
return [2000, 4000, 6000, 8000, 10000, 12000, 14000, 16000, 18000, 20000, 22000];
|
<SelectValue />
|
||||||
}
|
</SelectTrigger>
|
||||||
else if (nyquistFreq <= 48000) {
|
<SelectContent>
|
||||||
return [5000, 10000, 15000, 20000, 25000, 30000, 35000, 40000, 45000];
|
<SelectItem value="512">512</SelectItem>
|
||||||
}
|
<SelectItem value="1024">1024</SelectItem>
|
||||||
else if (nyquistFreq <= 96000) {
|
<SelectItem value="2048">2048</SelectItem>
|
||||||
return [10000, 20000, 30000, 40000, 50000, 60000, 70000, 80000, 90000];
|
<SelectItem value="4096">4096</SelectItem>
|
||||||
}
|
</SelectContent>
|
||||||
else {
|
</Select>
|
||||||
return [20000, 40000, 60000, 80000, 100000, 120000, 140000, 160000, 180000];
|
</div>
|
||||||
}
|
|
||||||
};
|
<div className="flex items-center gap-2">
|
||||||
const getTimeStep = (duration: number): number => {
|
<Label className="whitespace-nowrap text-sm font-medium">Window:</Label>
|
||||||
if (duration <= 60)
|
<Select value={windowFunction} onValueChange={(v) => handleReAnalyze(fftSize, v)} disabled={isAnalyzingSpectrum}>
|
||||||
return 15;
|
<SelectTrigger className="h-8 w-[120px] text-sm capitalize">
|
||||||
if (duration <= 120)
|
<SelectValue />
|
||||||
return 30;
|
</SelectTrigger>
|
||||||
if (duration <= 300)
|
<SelectContent>
|
||||||
return 30;
|
<SelectItem value="hann">Hann</SelectItem>
|
||||||
if (duration <= 600)
|
<SelectItem value="hamming">Hamming</SelectItem>
|
||||||
return 60;
|
<SelectItem value="blackman">Blackman</SelectItem>
|
||||||
return 60;
|
<SelectItem value="rectangular">Rectangular</SelectItem>
|
||||||
};
|
</SelectContent>
|
||||||
const drawColorBar = (ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number) => {
|
</Select>
|
||||||
for (let i = 0; i < height; i++) {
|
</div>
|
||||||
const intensity = 1 - (i / height);
|
</div>
|
||||||
const color = getSpekColor(intensity);
|
|
||||||
ctx.fillStyle = color;
|
<div className="relative border border-white/10 rounded-lg overflow-hidden bg-black shadow-xl">
|
||||||
ctx.fillRect(x, y + i, width, 1);
|
{isAnalyzingSpectrum && (<div className="absolute inset-0 z-10 grid place-items-center bg-black/60 backdrop-blur-sm">
|
||||||
}
|
<div className="w-full max-w-xs space-y-2 px-4">
|
||||||
ctx.strokeStyle = "#666666";
|
<div className="flex items-center justify-between text-sm text-foreground/90">
|
||||||
ctx.lineWidth = 1;
|
<span>Processing...</span>
|
||||||
ctx.strokeRect(x, y, width, height);
|
<span className="tabular-nums">{spectrumPercent}%</span>
|
||||||
ctx.fillStyle = "#FFFFFF";
|
</div>
|
||||||
ctx.font = "11px Arial";
|
<Progress value={spectrumPercent} className="h-2 w-full"/>
|
||||||
ctx.textAlign = "left";
|
</div>
|
||||||
ctx.textBaseline = "middle";
|
</div>)}
|
||||||
ctx.fillText("High", x + width + 5, y + 10);
|
<canvas ref={canvasRef} width={CANVAS_W} height={CANVAS_H} className="w-full h-auto" style={{ imageRendering: "auto" }}/>
|
||||||
ctx.fillText("Low", x + width + 5, y + height - 10);
|
</div>
|
||||||
};
|
</div>);
|
||||||
return (<div className="border border-white/10 rounded-lg overflow-hidden bg-black shadow-xl">
|
});
|
||||||
<canvas ref={canvasRef} width={1200} height={600} className="w-full h-auto" style={{ imageRendering: "auto" }}/>
|
|
||||||
</div>);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"use client";
|
||||||
|
import { motion, useAnimation } from "motion/react";
|
||||||
|
import type { HTMLAttributes } from "react";
|
||||||
|
import { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
export interface AudioLinesIconHandle {
|
||||||
|
startAnimation: () => void;
|
||||||
|
stopAnimation: () => void;
|
||||||
|
}
|
||||||
|
interface AudioLinesIconProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
const AudioLinesIcon = forwardRef<AudioLinesIconHandle, AudioLinesIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
|
||||||
|
const controls = useAnimation();
|
||||||
|
const isControlledRef = useRef(false);
|
||||||
|
useImperativeHandle(ref, () => {
|
||||||
|
isControlledRef.current = true;
|
||||||
|
return {
|
||||||
|
startAnimation: () => controls.start("animate"),
|
||||||
|
stopAnimation: () => controls.start("normal"),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const handleMouseEnter = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (isControlledRef.current) {
|
||||||
|
onMouseEnter?.(e);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
controls.start("animate");
|
||||||
|
}
|
||||||
|
}, [controls, onMouseEnter]);
|
||||||
|
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (isControlledRef.current) {
|
||||||
|
onMouseLeave?.(e);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
controls.start("normal");
|
||||||
|
}
|
||||||
|
}, [controls, onMouseLeave]);
|
||||||
|
return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
|
||||||
|
<svg fill="none" height={size} stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" viewBox="0 0 24 24" width={size} xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M2 10v3"/>
|
||||||
|
<motion.path animate={controls} d="M6 6v11" variants={{
|
||||||
|
normal: { d: "M6 6v11" },
|
||||||
|
animate: {
|
||||||
|
d: ["M6 6v11", "M6 10v3", "M6 6v11"],
|
||||||
|
transition: {
|
||||||
|
duration: 1.5,
|
||||||
|
repeat: Number.POSITIVE_INFINITY,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}/>
|
||||||
|
<motion.path animate={controls} d="M10 3v18" variants={{
|
||||||
|
normal: { d: "M10 3v18" },
|
||||||
|
animate: {
|
||||||
|
d: ["M10 3v18", "M10 9v5", "M10 3v18"],
|
||||||
|
transition: {
|
||||||
|
duration: 1,
|
||||||
|
repeat: Number.POSITIVE_INFINITY,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}/>
|
||||||
|
<motion.path animate={controls} d="M14 8v7" variants={{
|
||||||
|
normal: { d: "M14 8v7" },
|
||||||
|
animate: {
|
||||||
|
d: ["M14 8v7", "M14 6v11", "M14 8v7"],
|
||||||
|
transition: {
|
||||||
|
duration: 0.8,
|
||||||
|
repeat: Number.POSITIVE_INFINITY,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}/>
|
||||||
|
<motion.path animate={controls} d="M18 5v13" variants={{
|
||||||
|
normal: { d: "M18 5v13" },
|
||||||
|
animate: {
|
||||||
|
d: ["M18 5v13", "M18 7v9", "M18 5v13"],
|
||||||
|
transition: {
|
||||||
|
duration: 1.5,
|
||||||
|
repeat: Number.POSITIVE_INFINITY,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}/>
|
||||||
|
<path d="M22 10v3"/>
|
||||||
|
</svg>
|
||||||
|
</div>);
|
||||||
|
});
|
||||||
|
AudioLinesIcon.displayName = "AudioLinesIcon";
|
||||||
|
export { AudioLinesIcon };
|
||||||
@@ -1,147 +1,338 @@
|
|||||||
import { useState, useCallback, useEffect } from "react";
|
import { useState, useCallback, useRef, useEffect, type MutableRefObject } from "react";
|
||||||
import { AnalyzeTrack } from "../../wailsjs/go/main/App";
|
|
||||||
import type { AnalysisResult } from "@/types/api";
|
import type { AnalysisResult } from "@/types/api";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||||
import { setSpectrumCache, getSpectrumCache, clearSpectrumCache } from "@/lib/spectrum-cache";
|
import { analyzeAudioArrayBuffer, analyzeAudioFile, analyzeSpectrumFromSamples, type AnalysisProgress, } from "@/lib/flac-analysis";
|
||||||
const STORAGE_KEY = "spotiflac_audio_analysis_state";
|
import { loadAudioAnalysisPreferences } from "@/lib/audio-analysis-preferences";
|
||||||
|
type WindowFunction = "hann" | "hamming" | "blackman" | "rectangular";
|
||||||
|
function toWindowFunction(value: string): WindowFunction {
|
||||||
|
switch (value) {
|
||||||
|
case "hamming":
|
||||||
|
case "blackman":
|
||||||
|
case "rectangular":
|
||||||
|
return value;
|
||||||
|
case "hann":
|
||||||
|
default:
|
||||||
|
return "hann";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function fileNameFromPath(filePath: string): string {
|
||||||
|
const parts = filePath.split(/[/\\]/);
|
||||||
|
return parts[parts.length - 1] || filePath;
|
||||||
|
}
|
||||||
|
function nextUiTick(): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
}
|
||||||
|
async function base64ToArrayBuffer(base64: string, shouldCancel?: () => boolean): Promise<ArrayBuffer> {
|
||||||
|
const clean = base64.includes(",") ? base64.split(",")[1] : base64;
|
||||||
|
const padding = clean.endsWith("==") ? 2 : clean.endsWith("=") ? 1 : 0;
|
||||||
|
const outputLength = Math.floor((clean.length * 3) / 4) - padding;
|
||||||
|
const bytes = new Uint8Array(outputLength);
|
||||||
|
const chunkSize = 4 * 16384;
|
||||||
|
let writeOffset = 0;
|
||||||
|
for (let offset = 0; offset < clean.length; offset += chunkSize) {
|
||||||
|
if (shouldCancel?.()) {
|
||||||
|
throw new Error("Analysis cancelled");
|
||||||
|
}
|
||||||
|
const chunk = clean.slice(offset, Math.min(clean.length, offset + chunkSize));
|
||||||
|
const binary = atob(chunk);
|
||||||
|
for (let i = 0; i < binary.length; i++) {
|
||||||
|
bytes[writeOffset++] = binary.charCodeAt(i);
|
||||||
|
}
|
||||||
|
if ((offset / chunkSize) % 4 === 0) {
|
||||||
|
await nextUiTick();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bytes.buffer;
|
||||||
|
}
|
||||||
|
let sessionResult: AnalysisResult | null = null;
|
||||||
|
let sessionSelectedFilePath = "";
|
||||||
|
let sessionError: string | null = null;
|
||||||
|
let sessionSamples: Float32Array | null = null;
|
||||||
|
interface ProgressState {
|
||||||
|
percent: number;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
const DEFAULT_PROGRESS_STATE: ProgressState = {
|
||||||
|
percent: 0,
|
||||||
|
message: "Preparing analysis...",
|
||||||
|
};
|
||||||
|
interface CancelToken {
|
||||||
|
cancelled: boolean;
|
||||||
|
}
|
||||||
|
function cancelToken(tokenRef: MutableRefObject<CancelToken | null>): void {
|
||||||
|
if (tokenRef.current) {
|
||||||
|
tokenRef.current.cancelled = true;
|
||||||
|
tokenRef.current = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function createToken(tokenRef: MutableRefObject<CancelToken | null>): CancelToken {
|
||||||
|
cancelToken(tokenRef);
|
||||||
|
const token: CancelToken = { cancelled: false };
|
||||||
|
tokenRef.current = token;
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
function isCancelledError(error: unknown): boolean {
|
||||||
|
return error instanceof Error && error.message === "Analysis cancelled";
|
||||||
|
}
|
||||||
|
function toProgressState(progress: AnalysisProgress): ProgressState {
|
||||||
|
return {
|
||||||
|
percent: Math.round(Math.max(0, Math.min(100, progress.percent))),
|
||||||
|
message: progress.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
export function useAudioAnalysis() {
|
export function useAudioAnalysis() {
|
||||||
const [analyzing, setAnalyzing] = useState(false);
|
const [analyzing, setAnalyzing] = useState(false);
|
||||||
const [result, setResult] = useState<AnalysisResult | null>(() => {
|
const [analysisProgress, setAnalysisProgress] = useState<ProgressState>(DEFAULT_PROGRESS_STATE);
|
||||||
try {
|
const [result, setResult] = useState<AnalysisResult | null>(() => sessionResult);
|
||||||
const saved = sessionStorage.getItem(STORAGE_KEY);
|
const [selectedFilePath, setSelectedFilePath] = useState(() => sessionSelectedFilePath);
|
||||||
if (saved) {
|
const [error, setError] = useState<string | null>(() => sessionError);
|
||||||
const parsed = JSON.parse(saved);
|
const [spectrumLoading, setSpectrumLoading] = useState(false);
|
||||||
if (parsed.filePath && parsed.result) {
|
const [spectrumProgress, setSpectrumProgress] = useState<ProgressState>(DEFAULT_PROGRESS_STATE);
|
||||||
return {
|
const samplesRef = useRef<Float32Array | null>(sessionSamples);
|
||||||
...parsed.result,
|
const analysisTokenRef = useRef<CancelToken | null>(null);
|
||||||
spectrum: undefined,
|
const spectrumTokenRef = useRef<CancelToken | null>(null);
|
||||||
};
|
useEffect(() => {
|
||||||
}
|
return () => {
|
||||||
}
|
cancelToken(analysisTokenRef);
|
||||||
}
|
cancelToken(spectrumTokenRef);
|
||||||
catch (err) {
|
};
|
||||||
console.error("Failed to load saved analysis state:", err);
|
}, []);
|
||||||
}
|
const setResultWithSession = useCallback((next: AnalysisResult | null) => {
|
||||||
return null;
|
sessionResult = next;
|
||||||
});
|
setResult(next);
|
||||||
const [selectedFilePath, setSelectedFilePath] = useState<string>(() => {
|
}, []);
|
||||||
try {
|
const setSelectedFilePathWithSession = useCallback((next: string) => {
|
||||||
const saved = sessionStorage.getItem(STORAGE_KEY);
|
sessionSelectedFilePath = next;
|
||||||
if (saved) {
|
setSelectedFilePath(next);
|
||||||
const parsed = JSON.parse(saved);
|
}, []);
|
||||||
return parsed.filePath || "";
|
const setErrorWithSession = useCallback((next: string | null) => {
|
||||||
}
|
sessionError = next;
|
||||||
}
|
setError(next);
|
||||||
catch (err) {
|
}, []);
|
||||||
}
|
const analyzeFile = useCallback(async (file: File) => {
|
||||||
return "";
|
if (!file) {
|
||||||
});
|
setErrorWithSession("No file provided");
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [spectrumLoading, setSpectrumLoading] = useState(() => {
|
|
||||||
try {
|
|
||||||
const saved = sessionStorage.getItem(STORAGE_KEY);
|
|
||||||
if (saved) {
|
|
||||||
const parsed = JSON.parse(saved);
|
|
||||||
if (parsed.filePath && parsed.result) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (err) {
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
const analyzeFile = useCallback(async (filePath: string) => {
|
|
||||||
if (!filePath) {
|
|
||||||
setError("No file path provided");
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
const token = createToken(analysisTokenRef);
|
||||||
|
cancelToken(spectrumTokenRef);
|
||||||
setAnalyzing(true);
|
setAnalyzing(true);
|
||||||
setError(null);
|
setAnalysisProgress({
|
||||||
setResult(null);
|
percent: 1,
|
||||||
setSelectedFilePath(filePath);
|
message: "Preparing file...",
|
||||||
|
});
|
||||||
|
setErrorWithSession(null);
|
||||||
|
setResultWithSession(null);
|
||||||
|
setSelectedFilePathWithSession(file.name);
|
||||||
try {
|
try {
|
||||||
logger.info(`Analyzing audio file: ${filePath}`);
|
logger.info(`Analyzing audio file (frontend): ${file.name}`);
|
||||||
const startTime = Date.now();
|
const start = Date.now();
|
||||||
const response = await AnalyzeTrack(filePath);
|
const prefs = loadAudioAnalysisPreferences();
|
||||||
const analysisResult: AnalysisResult = JSON.parse(response);
|
const payload = await analyzeAudioFile(file, {
|
||||||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(2);
|
fftSize: prefs.fftSize,
|
||||||
|
windowFunction: prefs.windowFunction,
|
||||||
|
}, (progress) => {
|
||||||
|
if (token.cancelled)
|
||||||
|
return;
|
||||||
|
setAnalysisProgress(toProgressState(progress));
|
||||||
|
}, () => token.cancelled);
|
||||||
|
if (token.cancelled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
samplesRef.current = payload.samples;
|
||||||
|
sessionSamples = payload.samples;
|
||||||
|
setResultWithSession(payload.result);
|
||||||
|
const elapsed = ((Date.now() - start) / 1000).toFixed(2);
|
||||||
logger.success(`Audio analysis completed in ${elapsed}s`);
|
logger.success(`Audio analysis completed in ${elapsed}s`);
|
||||||
if (analysisResult.spectrum) {
|
return payload.result;
|
||||||
setSpectrumCache(filePath, analysisResult.spectrum);
|
|
||||||
}
|
|
||||||
const { spectrum, ...detailResult } = analysisResult;
|
|
||||||
try {
|
|
||||||
sessionStorage.setItem(STORAGE_KEY, JSON.stringify({
|
|
||||||
filePath,
|
|
||||||
result: detailResult,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
catch (err) {
|
|
||||||
console.error("Failed to save analysis state:", err);
|
|
||||||
}
|
|
||||||
setResult(analysisResult);
|
|
||||||
setSpectrumLoading(false);
|
|
||||||
return analysisResult;
|
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
|
if (isCancelledError(err)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const errorMessage = err instanceof Error ? err.message : "Failed to analyze audio file";
|
const errorMessage = err instanceof Error ? err.message : "Failed to analyze audio file";
|
||||||
logger.error(`Analysis error: ${errorMessage}`);
|
logger.error(`Analysis error: ${errorMessage}`);
|
||||||
setError(errorMessage);
|
setErrorWithSession(errorMessage);
|
||||||
|
setAnalysisProgress({
|
||||||
|
percent: 0,
|
||||||
|
message: "Analysis failed",
|
||||||
|
});
|
||||||
toast.error("Audio Analysis Failed", {
|
toast.error("Audio Analysis Failed", {
|
||||||
description: errorMessage,
|
description: errorMessage,
|
||||||
});
|
});
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
setAnalyzing(false);
|
if (analysisTokenRef.current === token) {
|
||||||
|
analysisTokenRef.current = null;
|
||||||
|
setAnalyzing(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, []);
|
}, [setErrorWithSession, setResultWithSession, setSelectedFilePathWithSession]);
|
||||||
const clearResult = useCallback(() => {
|
const analyzeFilePath = useCallback(async (filePath: string) => {
|
||||||
setResult(null);
|
if (!filePath) {
|
||||||
setError(null);
|
setErrorWithSession("No file path provided");
|
||||||
setSelectedFilePath("");
|
return null;
|
||||||
|
}
|
||||||
|
const token = createToken(analysisTokenRef);
|
||||||
|
cancelToken(spectrumTokenRef);
|
||||||
|
setAnalyzing(true);
|
||||||
|
setAnalysisProgress({
|
||||||
|
percent: 1,
|
||||||
|
message: "Reading file from disk...",
|
||||||
|
});
|
||||||
|
setErrorWithSession(null);
|
||||||
|
setResultWithSession(null);
|
||||||
|
setSelectedFilePathWithSession(filePath);
|
||||||
try {
|
try {
|
||||||
sessionStorage.removeItem(STORAGE_KEY);
|
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;
|
||||||
|
if (!readFileAsBase64) {
|
||||||
|
throw new Error("ReadFileAsBase64 backend method is unavailable");
|
||||||
|
}
|
||||||
|
let base64Data = await readFileAsBase64(filePath);
|
||||||
|
if (token.cancelled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
setAnalysisProgress({
|
||||||
|
percent: 10,
|
||||||
|
message: "File loaded",
|
||||||
|
});
|
||||||
|
const arrayBuffer = await base64ToArrayBuffer(base64Data, () => token.cancelled);
|
||||||
|
base64Data = "";
|
||||||
|
if (token.cancelled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
setAnalysisProgress({
|
||||||
|
percent: 15,
|
||||||
|
message: "Preparing audio buffer...",
|
||||||
|
});
|
||||||
|
const fileName = fileNameFromPath(filePath);
|
||||||
|
const payload = await analyzeAudioArrayBuffer({
|
||||||
|
fileName,
|
||||||
|
fileSize: arrayBuffer.byteLength,
|
||||||
|
arrayBuffer,
|
||||||
|
}, {
|
||||||
|
fftSize: prefs.fftSize,
|
||||||
|
windowFunction: prefs.windowFunction,
|
||||||
|
}, (progress) => {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
samplesRef.current = payload.samples;
|
||||||
|
sessionSamples = payload.samples;
|
||||||
|
setResultWithSession(payload.result);
|
||||||
|
const elapsed = ((Date.now() - start) / 1000).toFixed(2);
|
||||||
|
logger.success(`Audio analysis completed in ${elapsed}s`);
|
||||||
|
return payload.result;
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
}
|
if (isCancelledError(err)) {
|
||||||
clearSpectrumCache();
|
return null;
|
||||||
}, []);
|
|
||||||
useEffect(() => {
|
|
||||||
if (!result || !selectedFilePath || result.spectrum || !spectrumLoading) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let rafId: number;
|
|
||||||
const loadSpectrum = () => {
|
|
||||||
rafId = requestAnimationFrame(() => {
|
|
||||||
const cachedSpectrum = getSpectrumCache(selectedFilePath);
|
|
||||||
if (cachedSpectrum) {
|
|
||||||
setResult(prev => prev ? { ...prev, spectrum: cachedSpectrum } : null);
|
|
||||||
setSpectrumLoading(false);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
setSpectrumLoading(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
requestAnimationFrame(loadSpectrum);
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
if (rafId) {
|
|
||||||
cancelAnimationFrame(rafId);
|
|
||||||
}
|
}
|
||||||
};
|
const errorMessage = err instanceof Error ? err.message : "Failed to analyze audio file";
|
||||||
}, [result, selectedFilePath, spectrumLoading]);
|
logger.error(`Analysis error: ${errorMessage}`);
|
||||||
|
setErrorWithSession(errorMessage);
|
||||||
|
setAnalysisProgress({
|
||||||
|
percent: 0,
|
||||||
|
message: "Analysis failed",
|
||||||
|
});
|
||||||
|
toast.error("Audio Analysis Failed", {
|
||||||
|
description: errorMessage,
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
if (analysisTokenRef.current === token) {
|
||||||
|
analysisTokenRef.current = null;
|
||||||
|
setAnalyzing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [setErrorWithSession, setResultWithSession, setSelectedFilePathWithSession]);
|
||||||
|
const reAnalyzeSpectrum = useCallback(async (fftSize: number, windowFunction: string) => {
|
||||||
|
if (!result || !samplesRef.current)
|
||||||
|
return;
|
||||||
|
const token = createToken(spectrumTokenRef);
|
||||||
|
setSpectrumLoading(true);
|
||||||
|
setSpectrumProgress({
|
||||||
|
percent: 0,
|
||||||
|
message: "Preparing FFT...",
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||||
|
const spectrum = await analyzeSpectrumFromSamples(samplesRef.current, result.sample_rate, {
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
if (isCancelledError(err)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const errorMessage = err instanceof Error ? err.message : "Failed to re-analyze spectrum";
|
||||||
|
logger.error(`Spectrum re-analysis error: ${errorMessage}`);
|
||||||
|
setSpectrumProgress({
|
||||||
|
percent: 0,
|
||||||
|
message: "Spectrum analysis failed",
|
||||||
|
});
|
||||||
|
toast.error("Spectrum Analysis Failed", {
|
||||||
|
description: errorMessage,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
if (spectrumTokenRef.current === token) {
|
||||||
|
spectrumTokenRef.current = null;
|
||||||
|
setSpectrumLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [result]);
|
||||||
|
const clearResult = useCallback(() => {
|
||||||
|
cancelToken(analysisTokenRef);
|
||||||
|
cancelToken(spectrumTokenRef);
|
||||||
|
setAnalyzing(false);
|
||||||
|
setResultWithSession(null);
|
||||||
|
setErrorWithSession(null);
|
||||||
|
setSelectedFilePathWithSession("");
|
||||||
|
setSpectrumLoading(false);
|
||||||
|
setAnalysisProgress(DEFAULT_PROGRESS_STATE);
|
||||||
|
setSpectrumProgress(DEFAULT_PROGRESS_STATE);
|
||||||
|
samplesRef.current = null;
|
||||||
|
sessionSamples = null;
|
||||||
|
}, [setErrorWithSession, setResultWithSession, setSelectedFilePathWithSession]);
|
||||||
return {
|
return {
|
||||||
analyzing,
|
analyzing,
|
||||||
|
analysisProgress,
|
||||||
result,
|
result,
|
||||||
error,
|
error,
|
||||||
selectedFilePath,
|
selectedFilePath,
|
||||||
spectrumLoading,
|
spectrumLoading,
|
||||||
|
spectrumProgress,
|
||||||
analyzeFile,
|
analyzeFile,
|
||||||
|
analyzeFilePath,
|
||||||
|
reAnalyzeSpectrum,
|
||||||
clearResult,
|
clearResult,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
import { useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { getSettings } from "@/lib/settings";
|
import { getSettings } from "@/lib/settings";
|
||||||
import { fetchSpotifyMetadata } from "@/lib/api";
|
import { fetchSpotifyMetadata } from "@/lib/api";
|
||||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
import { AddFetchHistory } from "../../wailsjs/go/main/App";
|
import { AddFetchHistory } from "../../wailsjs/go/main/App";
|
||||||
|
import { EventsOff, EventsOn } from "../../wailsjs/runtime/runtime";
|
||||||
import type { SpotifyMetadataResponse } from "@/types/api";
|
import type { SpotifyMetadataResponse } from "@/types/api";
|
||||||
export function useMetadata() {
|
export function useMetadata() {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [metadata, setMetadata] = useState<SpotifyMetadataResponse | null>(null);
|
const [metadata, setMetadata] = useState<SpotifyMetadataResponse | null>(null);
|
||||||
|
const loadingToastId = useRef<string | number | null>(null);
|
||||||
|
const fetchedCount = useRef(0);
|
||||||
|
const currentName = useRef("");
|
||||||
const [showApiModal, setShowApiModal] = useState(false);
|
const [showApiModal, setShowApiModal] = useState(false);
|
||||||
const [showAlbumDialog, setShowAlbumDialog] = useState(false);
|
const [showAlbumDialog, setShowAlbumDialog] = useState(false);
|
||||||
const [selectedAlbum, setSelectedAlbum] = useState<{
|
const [selectedAlbum, setSelectedAlbum] = useState<{
|
||||||
@@ -16,6 +20,73 @@ export function useMetadata() {
|
|||||||
external_urls: string;
|
external_urls: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [pendingArtistName, setPendingArtistName] = useState<string | null>(null);
|
const [pendingArtistName, setPendingArtistName] = useState<string | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (loading) {
|
||||||
|
fetchedCount.current = 0;
|
||||||
|
currentName.current = "";
|
||||||
|
loadingToastId.current = toast.silentInfo("fetching metadata...", {
|
||||||
|
duration: Infinity,
|
||||||
|
description: "please wait while we retrieve the information"
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (loadingToastId.current) {
|
||||||
|
toast.dismiss(loadingToastId.current);
|
||||||
|
loadingToastId.current = null;
|
||||||
|
}
|
||||||
|
}, [loading]);
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (data: any) => {
|
||||||
|
if (!data) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
fetchedCount.current += data.length;
|
||||||
|
if (loadingToastId.current && currentName.current) {
|
||||||
|
toast.silentInfo(`fetching tracks for ${currentName.current.toLowerCase()}...`, {
|
||||||
|
id: loadingToastId.current,
|
||||||
|
description: `${fetchedCount.current.toLocaleString()} tracks fetched`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
const baseInfo = data;
|
||||||
|
const name = "artist_info" in baseInfo ? baseInfo.artist_info.name :
|
||||||
|
"album_info" in baseInfo ? baseInfo.album_info.name :
|
||||||
|
"playlist_info" in baseInfo ? (baseInfo.playlist_info.name || baseInfo.playlist_info.owner.name) : "";
|
||||||
|
if (name) {
|
||||||
|
currentName.current = name;
|
||||||
|
if (loadingToastId.current) {
|
||||||
|
toast.silentInfo(`fetching tracks for ${name.toLowerCase()}...`, {
|
||||||
|
id: loadingToastId.current,
|
||||||
|
description: `${fetchedCount.current.toLocaleString()} tracks fetched`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setMetadata(prev => {
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
if (!prev || !("track_list" in prev)) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
track_list: [...prev.track_list, ...data]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (prev && "track_list" in prev && prev.track_list.length > 0) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
const baseInfo = data;
|
||||||
|
if (!("track_list" in baseInfo)) {
|
||||||
|
baseInfo.track_list = [];
|
||||||
|
}
|
||||||
|
return baseInfo;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
EventsOn("metadata-stream", handler);
|
||||||
|
return () => EventsOff("metadata-stream");
|
||||||
|
}, []);
|
||||||
const getUrlType = (url: string): string => {
|
const getUrlType = (url: string): string => {
|
||||||
if (url.includes("/track/"))
|
if (url.includes("/track/"))
|
||||||
return "track";
|
return "track";
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { GetPreviewURL } from "@/../wailsjs/go/main/App";
|
import { GetPreviewURL } from "@/../wailsjs/go/main/App";
|
||||||
|
import { SPOTIFY_PREVIEW_VOLUME } from "@/lib/preview";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
export function usePreview() {
|
export function usePreview() {
|
||||||
const [loadingPreview, setLoadingPreview] = useState<string | null>(null);
|
const [loadingPreview, setLoadingPreview] = useState<string | null>(null);
|
||||||
@@ -38,6 +39,7 @@ export function usePreview() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const audio = new Audio(previewURL);
|
const audio = new Audio(previewURL);
|
||||||
|
audio.volume = SPOTIFY_PREVIEW_VOLUME;
|
||||||
audio.addEventListener("loadeddata", () => {
|
audio.addEventListener("loadeddata", () => {
|
||||||
setLoadingPreview(null);
|
setLoadingPreview(null);
|
||||||
setPlayingTrack(trackId);
|
setPlayingTrack(trackId);
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
export type AnalyzerColorScheme = "spek" | "viridis" | "hot" | "cool" | "grayscale";
|
||||||
|
export type AnalyzerFreqScale = "linear" | "log2";
|
||||||
|
export type AnalyzerWindowFunction = "hann" | "hamming" | "blackman" | "rectangular";
|
||||||
|
export interface AudioAnalysisPreferences {
|
||||||
|
colorScheme: AnalyzerColorScheme;
|
||||||
|
freqScale: AnalyzerFreqScale;
|
||||||
|
fftSize: number;
|
||||||
|
windowFunction: AnalyzerWindowFunction;
|
||||||
|
}
|
||||||
|
const STORAGE_KEY = "spotiflac_audio_analysis_preferences";
|
||||||
|
const DEFAULT_PREFERENCES: AudioAnalysisPreferences = {
|
||||||
|
colorScheme: "spek",
|
||||||
|
freqScale: "linear",
|
||||||
|
fftSize: 4096,
|
||||||
|
windowFunction: "hann",
|
||||||
|
};
|
||||||
|
const FFT_SIZE_SET = new Set([512, 1024, 2048, 4096]);
|
||||||
|
function toColorScheme(value: unknown): AnalyzerColorScheme {
|
||||||
|
return value === "viridis" || value === "hot" || value === "cool" || value === "grayscale"
|
||||||
|
? value
|
||||||
|
: "spek";
|
||||||
|
}
|
||||||
|
function toFreqScale(value: unknown): AnalyzerFreqScale {
|
||||||
|
return value === "log2" ? "log2" : "linear";
|
||||||
|
}
|
||||||
|
function toFFTSize(value: unknown): number {
|
||||||
|
const num = typeof value === "number" ? value : Number(value);
|
||||||
|
return FFT_SIZE_SET.has(num) ? num : 4096;
|
||||||
|
}
|
||||||
|
function toWindowFunction(value: unknown): AnalyzerWindowFunction {
|
||||||
|
return value === "hamming" || value === "blackman" || value === "rectangular"
|
||||||
|
? value
|
||||||
|
: "hann";
|
||||||
|
}
|
||||||
|
export function loadAudioAnalysisPreferences(): AudioAnalysisPreferences {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!raw)
|
||||||
|
return DEFAULT_PREFERENCES;
|
||||||
|
const parsed = JSON.parse(raw) as Partial<AudioAnalysisPreferences>;
|
||||||
|
return {
|
||||||
|
colorScheme: toColorScheme(parsed.colorScheme),
|
||||||
|
freqScale: toFreqScale(parsed.freqScale),
|
||||||
|
fftSize: toFFTSize(parsed.fftSize),
|
||||||
|
windowFunction: toWindowFunction(parsed.windowFunction),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return DEFAULT_PREFERENCES;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export function saveAudioAnalysisPreferences(preferences: AudioAnalysisPreferences): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify({
|
||||||
|
colorScheme: toColorScheme(preferences.colorScheme),
|
||||||
|
freqScale: toFreqScale(preferences.freqScale),
|
||||||
|
fftSize: toFFTSize(preferences.fftSize),
|
||||||
|
windowFunction: toWindowFunction(preferences.windowFunction),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,727 @@
|
|||||||
|
import type { AnalysisResult, SpectrumData, TimeSlice } from "@/types/api";
|
||||||
|
export interface SpectrumParams {
|
||||||
|
fftSize: number;
|
||||||
|
windowFunction: "hann" | "hamming" | "blackman" | "rectangular";
|
||||||
|
}
|
||||||
|
const DEFAULT_PARAMS: SpectrumParams = {
|
||||||
|
fftSize: 4096,
|
||||||
|
windowFunction: "hann",
|
||||||
|
};
|
||||||
|
const MAX_SPECTRUM_FRAMES = 2200;
|
||||||
|
const METRICS_CHUNK_SIZE = 262144;
|
||||||
|
const AAC_SAMPLE_RATES = [
|
||||||
|
96000, 88200, 64000, 48000, 44100, 32000, 24000,
|
||||||
|
22050, 16000, 12000, 11025, 8000, 7350,
|
||||||
|
] as const;
|
||||||
|
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 {
|
||||||
|
fileType: SupportedAudioFileType;
|
||||||
|
sampleRate: number;
|
||||||
|
channels: number;
|
||||||
|
bitsPerSample: number;
|
||||||
|
totalSamples: number;
|
||||||
|
duration: number;
|
||||||
|
codecMode?: string;
|
||||||
|
bitrateKbps?: number;
|
||||||
|
totalFrames?: number;
|
||||||
|
codecVersion?: string;
|
||||||
|
}
|
||||||
|
interface Mp4BoxInfo {
|
||||||
|
offset: number;
|
||||||
|
size: number;
|
||||||
|
headerSize: number;
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
export interface FrontendAnalysisPayload {
|
||||||
|
result: AnalysisResult;
|
||||||
|
samples: Float32Array;
|
||||||
|
}
|
||||||
|
export interface AudioArrayBufferInput {
|
||||||
|
fileName: string;
|
||||||
|
fileSize: number;
|
||||||
|
arrayBuffer: ArrayBuffer;
|
||||||
|
}
|
||||||
|
export type AnalysisPhase = "read" | "parse" | "decode" | "metrics" | "spectrum" | "finalize";
|
||||||
|
export interface AnalysisProgress {
|
||||||
|
phase: AnalysisPhase;
|
||||||
|
percent: number;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
export type AnalysisProgressCallback = (progress: AnalysisProgress) => void;
|
||||||
|
export type AnalysisCancelCheck = () => boolean;
|
||||||
|
function reportProgress(callback: AnalysisProgressCallback | undefined, phase: AnalysisPhase, percent: number, message: string): void {
|
||||||
|
if (!callback)
|
||||||
|
return;
|
||||||
|
callback({
|
||||||
|
phase,
|
||||||
|
percent: Math.max(0, Math.min(100, percent)),
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function throwIfCancelled(cancelCheck?: AnalysisCancelCheck): void {
|
||||||
|
if (cancelCheck?.()) {
|
||||||
|
throw new Error("Analysis cancelled");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function nowMs(): number {
|
||||||
|
return typeof performance !== "undefined" ? performance.now() : Date.now();
|
||||||
|
}
|
||||||
|
function nextTick(): Promise<void> {
|
||||||
|
if (typeof requestAnimationFrame === "function") {
|
||||||
|
return new Promise((resolve) => requestAnimationFrame(() => resolve()));
|
||||||
|
}
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
}
|
||||||
|
function readFourCC(view: DataView, offset: number): string {
|
||||||
|
return String.fromCharCode(view.getUint8(offset), view.getUint8(offset + 1), view.getUint8(offset + 2), view.getUint8(offset + 3));
|
||||||
|
}
|
||||||
|
function fileExtension(fileName: string): string {
|
||||||
|
const normalized = fileName.toLowerCase();
|
||||||
|
const dotIndex = normalized.lastIndexOf(".");
|
||||||
|
return dotIndex >= 0 ? normalized.slice(dotIndex) : "";
|
||||||
|
}
|
||||||
|
function detectAudioFileType(buffer: ArrayBuffer, fileName = ""): SupportedAudioFileType {
|
||||||
|
const view = new DataView(buffer);
|
||||||
|
if (view.byteLength >= 4 && view.getUint32(0, false) === 0x664c6143) {
|
||||||
|
return "FLAC";
|
||||||
|
}
|
||||||
|
if (view.byteLength >= 3 &&
|
||||||
|
view.getUint8(0) === 0x49 &&
|
||||||
|
view.getUint8(1) === 0x44 &&
|
||||||
|
view.getUint8(2) === 0x33) {
|
||||||
|
return "MP3";
|
||||||
|
}
|
||||||
|
if (view.byteLength >= 8 && readFourCC(view, 4) === "ftyp") {
|
||||||
|
return "M4A";
|
||||||
|
}
|
||||||
|
if (view.byteLength >= 2 && view.getUint8(0) === 0xff && (view.getUint8(1) & 0xf6) === 0xf0) {
|
||||||
|
return "AAC";
|
||||||
|
}
|
||||||
|
for (let offset = 0; offset < Math.min(4096, view.byteLength - 4); offset++) {
|
||||||
|
const header = view.getUint32(offset, false);
|
||||||
|
if ((header >>> 21) === 0x7ff) {
|
||||||
|
const version = (header >>> 19) & 0x03;
|
||||||
|
const layer = (header >>> 17) & 0x03;
|
||||||
|
const sampleRateIndex = (header >>> 10) & 0x03;
|
||||||
|
if (version !== 1 && layer !== 0 && sampleRateIndex !== 3) {
|
||||||
|
return "MP3";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch (fileExtension(fileName)) {
|
||||||
|
case ".flac": return "FLAC";
|
||||||
|
case ".mp3": return "MP3";
|
||||||
|
case ".m4a":
|
||||||
|
case ".mp4": return "M4A";
|
||||||
|
case ".aac": return "AAC";
|
||||||
|
default: throw new Error(`Unsupported audio format: ${fileName || "unknown"}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function parseFlacMetadata(buffer: ArrayBuffer): ParsedAudioMetadata {
|
||||||
|
const data = new Uint8Array(buffer);
|
||||||
|
if (data.length < 4 || data[0] !== 0x66 || data[1] !== 0x4c || data[2] !== 0x61 || data[3] !== 0x43) {
|
||||||
|
throw new Error("Invalid FLAC file");
|
||||||
|
}
|
||||||
|
let offset = 4;
|
||||||
|
while (offset + 4 <= data.length) {
|
||||||
|
const blockHeader = data[offset];
|
||||||
|
const blockType = blockHeader & 0x7f;
|
||||||
|
const blockLength = (data[offset + 1] << 16) | (data[offset + 2] << 8) | data[offset + 3];
|
||||||
|
offset += 4;
|
||||||
|
if (offset + blockLength > data.length)
|
||||||
|
break;
|
||||||
|
if (blockType === 0 && blockLength >= 18) {
|
||||||
|
const streamInfo = data.subarray(offset, offset + blockLength);
|
||||||
|
const sampleRate = (streamInfo[10] << 12) | (streamInfo[11] << 4) | (streamInfo[12] >> 4);
|
||||||
|
const channels = ((streamInfo[12] >> 1) & 0x07) + 1;
|
||||||
|
const bitsPerSample = (((streamInfo[12] & 0x01) << 4) | (streamInfo[13] >> 4)) + 1;
|
||||||
|
const totalSamplesBig = (BigInt(streamInfo[13] & 0x0f) << 32n) |
|
||||||
|
(BigInt(streamInfo[14]) << 24n) |
|
||||||
|
(BigInt(streamInfo[15]) << 16n) |
|
||||||
|
(BigInt(streamInfo[16]) << 8n) |
|
||||||
|
BigInt(streamInfo[17]);
|
||||||
|
const totalSamples = Number(totalSamplesBig);
|
||||||
|
const duration = sampleRate > 0 && totalSamples > 0 ? totalSamples / sampleRate : 0;
|
||||||
|
return {
|
||||||
|
fileType: "FLAC",
|
||||||
|
sampleRate,
|
||||||
|
channels,
|
||||||
|
bitsPerSample,
|
||||||
|
totalSamples,
|
||||||
|
duration,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
offset += blockLength;
|
||||||
|
}
|
||||||
|
throw new Error("FLAC STREAMINFO metadata not found");
|
||||||
|
}
|
||||||
|
function skipId3v2Tag(view: DataView): number {
|
||||||
|
if (view.byteLength < 10 ||
|
||||||
|
view.getUint8(0) !== 0x49 ||
|
||||||
|
view.getUint8(1) !== 0x44 ||
|
||||||
|
view.getUint8(2) !== 0x33) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const size = ((view.getUint8(6) & 0x7f) << 21) |
|
||||||
|
((view.getUint8(7) & 0x7f) << 14) |
|
||||||
|
((view.getUint8(8) & 0x7f) << 7) |
|
||||||
|
(view.getUint8(9) & 0x7f);
|
||||||
|
let offset = 10 + size;
|
||||||
|
if ((view.getUint8(5) & 0x10) !== 0) {
|
||||||
|
offset += 10;
|
||||||
|
}
|
||||||
|
return offset < view.byteLength ? offset : 0;
|
||||||
|
}
|
||||||
|
function getMp3Bitrate(version: number, layer: number, bitrateIndex: number): number {
|
||||||
|
const tables: Record<number, Record<number, number[]>> = {
|
||||||
|
1: {
|
||||||
|
1: [0, 32, 64, 96, 128, 160, 192, 224, 256, 288, 320, 352, 384, 416, 448, 0],
|
||||||
|
2: [0, 32, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 384, 0],
|
||||||
|
3: [0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0],
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
1: [0, 32, 48, 56, 64, 80, 96, 112, 128, 144, 160, 176, 192, 224, 256, 0],
|
||||||
|
2: [0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0],
|
||||||
|
3: [0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const normalizedVersion = version === 2.5 ? 2 : version;
|
||||||
|
return tables[normalizedVersion]?.[layer]?.[bitrateIndex] ?? 0;
|
||||||
|
}
|
||||||
|
function getMp3SamplesPerFrame(version: number, layer: number): number {
|
||||||
|
if (layer === 1)
|
||||||
|
return 384;
|
||||||
|
if (version === 1)
|
||||||
|
return 1152;
|
||||||
|
return 576;
|
||||||
|
}
|
||||||
|
interface Mp3FrameInfo {
|
||||||
|
version: number;
|
||||||
|
versionName: string;
|
||||||
|
layer: number;
|
||||||
|
sampleRate: number;
|
||||||
|
bitrate: number;
|
||||||
|
channels: number;
|
||||||
|
frameSize: number;
|
||||||
|
samplesPerFrame: number;
|
||||||
|
}
|
||||||
|
function parseMp3FrameHeader(header: number): Mp3FrameInfo | null {
|
||||||
|
if (((header >>> 21) & 0x7ff) !== 0x7ff)
|
||||||
|
return null;
|
||||||
|
const versionBits = (header >>> 19) & 0x03;
|
||||||
|
const layerBits = (header >>> 17) & 0x03;
|
||||||
|
const bitrateIndex = (header >>> 12) & 0x0f;
|
||||||
|
const sampleRateIndex = (header >>> 10) & 0x03;
|
||||||
|
const padding = (header >>> 9) & 0x01;
|
||||||
|
const channelMode = (header >>> 6) & 0x03;
|
||||||
|
const versions = [2.5, null, 2, 1] as const;
|
||||||
|
const layers = [null, 3, 2, 1] as const;
|
||||||
|
const version = versions[versionBits];
|
||||||
|
const layer = layers[layerBits];
|
||||||
|
if (version === null || layer === null || sampleRateIndex === 3)
|
||||||
|
return null;
|
||||||
|
const sampleRateTables: Record<1 | 2 | 25, [
|
||||||
|
number,
|
||||||
|
number,
|
||||||
|
number
|
||||||
|
]> = {
|
||||||
|
1: [44100, 48000, 32000],
|
||||||
|
2: [22050, 24000, 16000],
|
||||||
|
25: [11025, 12000, 8000],
|
||||||
|
};
|
||||||
|
const sampleRateKey = version === 2.5 ? 25 : (version as 1 | 2);
|
||||||
|
const sampleRate = sampleRateTables[sampleRateKey][sampleRateIndex];
|
||||||
|
const bitrate = getMp3Bitrate(version, layer, bitrateIndex);
|
||||||
|
const samplesPerFrame = getMp3SamplesPerFrame(version, layer);
|
||||||
|
if (!sampleRate || !bitrate || !samplesPerFrame)
|
||||||
|
return null;
|
||||||
|
return {
|
||||||
|
version,
|
||||||
|
versionName: `MPEG-${version === 1 ? "1" : version === 2 ? "2" : "2.5"}`,
|
||||||
|
layer,
|
||||||
|
sampleRate,
|
||||||
|
bitrate,
|
||||||
|
channels: channelMode === 3 ? 1 : 2,
|
||||||
|
frameSize: Math.floor((samplesPerFrame / 8 * bitrate * 1000) / sampleRate) + padding,
|
||||||
|
samplesPerFrame,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function getMp3SideInfoSize(frameInfo: Mp3FrameInfo): number {
|
||||||
|
if (frameInfo.version === 1) {
|
||||||
|
return frameInfo.channels === 1 ? 17 : 32;
|
||||||
|
}
|
||||||
|
return frameInfo.channels === 1 ? 9 : 17;
|
||||||
|
}
|
||||||
|
function parseMp3XingHeader(view: DataView, offset: number, frameInfo: Mp3FrameInfo) {
|
||||||
|
if (offset + 16 > view.byteLength)
|
||||||
|
return null;
|
||||||
|
const flags = view.getUint32(offset + 4, false);
|
||||||
|
let pos = offset + 8;
|
||||||
|
let totalFrames = 0;
|
||||||
|
let totalBytes = 0;
|
||||||
|
if ((flags & 0x01) !== 0 && pos + 4 <= view.byteLength) {
|
||||||
|
totalFrames = view.getUint32(pos, false);
|
||||||
|
pos += 4;
|
||||||
|
}
|
||||||
|
if ((flags & 0x02) !== 0 && pos + 4 <= view.byteLength) {
|
||||||
|
totalBytes = view.getUint32(pos, false);
|
||||||
|
}
|
||||||
|
const duration = totalFrames > 0 ? (totalFrames * frameInfo.samplesPerFrame) / frameInfo.sampleRate : 0;
|
||||||
|
const avgBitrate = duration > 0 && totalBytes > 0 ? Math.round((totalBytes * 8) / duration / 1000) : frameInfo.bitrate;
|
||||||
|
return {
|
||||||
|
codecMode: "VBR (Xing)",
|
||||||
|
totalFrames,
|
||||||
|
duration,
|
||||||
|
bitrateKbps: avgBitrate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function parseMp3VbriHeader(view: DataView, offset: number, frameInfo: Mp3FrameInfo) {
|
||||||
|
if (offset + 18 > view.byteLength)
|
||||||
|
return null;
|
||||||
|
const totalBytes = view.getUint32(offset + 10, false);
|
||||||
|
const totalFrames = view.getUint32(offset + 14, false);
|
||||||
|
const duration = totalFrames > 0 ? (totalFrames * frameInfo.samplesPerFrame) / frameInfo.sampleRate : 0;
|
||||||
|
const bitrateKbps = duration > 0 && totalBytes > 0 ? Math.round((totalBytes * 8) / duration / 1000) : frameInfo.bitrate;
|
||||||
|
return {
|
||||||
|
codecMode: "VBR (VBRI)",
|
||||||
|
totalFrames,
|
||||||
|
duration,
|
||||||
|
bitrateKbps,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function parseMp3VbrInfo(view: DataView, frameOffset: number, frameInfo: Mp3FrameInfo) {
|
||||||
|
const sideInfoSize = getMp3SideInfoSize(frameInfo);
|
||||||
|
const xingOffset = frameOffset + 4 + sideInfoSize;
|
||||||
|
if (xingOffset + 4 <= view.byteLength) {
|
||||||
|
const xingTag = String.fromCharCode(view.getUint8(xingOffset), view.getUint8(xingOffset + 1), view.getUint8(xingOffset + 2), view.getUint8(xingOffset + 3));
|
||||||
|
if (xingTag === "Xing" || xingTag === "Info") {
|
||||||
|
return parseMp3XingHeader(view, xingOffset, frameInfo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const vbriOffset = frameOffset + 36;
|
||||||
|
if (vbriOffset + 4 <= view.byteLength) {
|
||||||
|
const vbriTag = String.fromCharCode(view.getUint8(vbriOffset), view.getUint8(vbriOffset + 1), view.getUint8(vbriOffset + 2), view.getUint8(vbriOffset + 3));
|
||||||
|
if (vbriTag === "VBRI") {
|
||||||
|
return parseMp3VbriHeader(view, vbriOffset, frameInfo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
function parseMp3Metadata(buffer: ArrayBuffer): ParsedAudioMetadata {
|
||||||
|
const view = new DataView(buffer);
|
||||||
|
const startOffset = skipId3v2Tag(view);
|
||||||
|
for (let offset = startOffset; offset <= view.byteLength - 4; offset++) {
|
||||||
|
const header = view.getUint32(offset, false);
|
||||||
|
const frameInfo = parseMp3FrameHeader(header);
|
||||||
|
if (frameInfo) {
|
||||||
|
const vbrInfo = parseMp3VbrInfo(view, offset, frameInfo);
|
||||||
|
const estimatedAudioDataSize = Math.max(0, view.byteLength - offset);
|
||||||
|
const estimatedFrameSize = frameInfo.frameSize > 0 ? frameInfo.frameSize : 1;
|
||||||
|
const totalFrames = vbrInfo?.totalFrames ?? Math.floor(estimatedAudioDataSize / estimatedFrameSize);
|
||||||
|
const duration = vbrInfo?.duration ?? ((totalFrames * frameInfo.samplesPerFrame) / frameInfo.sampleRate);
|
||||||
|
const bitrateKbps = vbrInfo?.bitrateKbps ?? frameInfo.bitrate;
|
||||||
|
return {
|
||||||
|
fileType: "MP3",
|
||||||
|
sampleRate: frameInfo.sampleRate,
|
||||||
|
channels: frameInfo.channels,
|
||||||
|
bitsPerSample: 16,
|
||||||
|
totalSamples: duration > 0 ? Math.floor(duration * frameInfo.sampleRate) : 0,
|
||||||
|
duration,
|
||||||
|
codecMode: vbrInfo?.codecMode ?? "CBR",
|
||||||
|
bitrateKbps,
|
||||||
|
totalFrames,
|
||||||
|
codecVersion: frameInfo.versionName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error("No valid MP3 frame found");
|
||||||
|
}
|
||||||
|
function parseAacMetadata(buffer: ArrayBuffer): ParsedAudioMetadata {
|
||||||
|
const data = new Uint8Array(buffer);
|
||||||
|
for (let offset = 0; offset <= data.length - 7; offset++) {
|
||||||
|
if (data[offset] !== 0xff || (data[offset + 1] & 0xf6) !== 0xf0)
|
||||||
|
continue;
|
||||||
|
const sampleRateIndex = (data[offset + 2] >> 2) & 0x0f;
|
||||||
|
const sampleRate = AAC_SAMPLE_RATES[sampleRateIndex];
|
||||||
|
const channels = ((data[offset + 2] & 0x01) << 2) | ((data[offset + 3] >> 6) & 0x03);
|
||||||
|
if (!sampleRate)
|
||||||
|
continue;
|
||||||
|
return {
|
||||||
|
fileType: "AAC",
|
||||||
|
sampleRate,
|
||||||
|
channels: channels || 2,
|
||||||
|
bitsPerSample: 16,
|
||||||
|
totalSamples: 0,
|
||||||
|
duration: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw new Error("No valid AAC ADTS header found");
|
||||||
|
}
|
||||||
|
function readMp4Box(view: DataView, offset: number, limit: number): Mp4BoxInfo | null {
|
||||||
|
if (offset + 8 > limit)
|
||||||
|
return null;
|
||||||
|
let size = view.getUint32(offset, false);
|
||||||
|
const type = readFourCC(view, offset + 4);
|
||||||
|
let headerSize = 8;
|
||||||
|
if (size === 1) {
|
||||||
|
if (offset + 16 > limit)
|
||||||
|
return null;
|
||||||
|
const high = view.getUint32(offset + 8, false);
|
||||||
|
const low = view.getUint32(offset + 12, false);
|
||||||
|
size = high * 4294967296 + low;
|
||||||
|
headerSize = 16;
|
||||||
|
}
|
||||||
|
else if (size === 0) {
|
||||||
|
size = limit - offset;
|
||||||
|
}
|
||||||
|
if (size < headerSize || offset + size > limit)
|
||||||
|
return null;
|
||||||
|
return { offset, size, headerSize, type };
|
||||||
|
}
|
||||||
|
function parseM4aMetadata(buffer: ArrayBuffer): ParsedAudioMetadata {
|
||||||
|
const view = new DataView(buffer);
|
||||||
|
let sampleRate = 0;
|
||||||
|
let channels = 0;
|
||||||
|
let bitsPerSample = 0;
|
||||||
|
let duration = 0;
|
||||||
|
const scanBoxes = (start: number, end: number): void => {
|
||||||
|
let offset = start;
|
||||||
|
while (offset + 8 <= end) {
|
||||||
|
const box = readMp4Box(view, offset, end);
|
||||||
|
if (!box)
|
||||||
|
break;
|
||||||
|
const boxEnd = box.offset + box.size;
|
||||||
|
const contentStart = box.offset + box.headerSize;
|
||||||
|
if (box.type === "mdhd" && contentStart + 24 <= boxEnd) {
|
||||||
|
const version = view.getUint8(contentStart);
|
||||||
|
if (version === 0 && contentStart + 24 <= boxEnd) {
|
||||||
|
const timeScale = view.getUint32(contentStart + 12, false);
|
||||||
|
const durationValue = view.getUint32(contentStart + 16, false);
|
||||||
|
if (timeScale > 0) {
|
||||||
|
sampleRate = timeScale;
|
||||||
|
duration = durationValue / timeScale;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (version === 1 && contentStart + 36 <= boxEnd) {
|
||||||
|
const timeScale = view.getUint32(contentStart + 20, false);
|
||||||
|
const durationHigh = view.getUint32(contentStart + 24, false);
|
||||||
|
const durationLow = view.getUint32(contentStart + 28, false);
|
||||||
|
const durationValue = durationHigh * 4294967296 + durationLow;
|
||||||
|
if (timeScale > 0) {
|
||||||
|
sampleRate = timeScale;
|
||||||
|
duration = durationValue / timeScale;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if ((box.type === "mp4a" || box.type === "aac ") && box.offset + 36 <= boxEnd) {
|
||||||
|
channels = view.getUint16(box.offset + 24, false) || channels;
|
||||||
|
bitsPerSample = view.getUint16(box.offset + 26, false) || bitsPerSample;
|
||||||
|
if (!sampleRate) {
|
||||||
|
const fixedPointSampleRate = view.getUint32(box.offset + 32, false);
|
||||||
|
if (fixedPointSampleRate > 0) {
|
||||||
|
sampleRate = Math.floor(fixedPointSampleRate / 65536);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (MP4_CONTAINER_TYPES.has(box.type)) {
|
||||||
|
let childStart = contentStart;
|
||||||
|
if (box.type === "meta")
|
||||||
|
childStart = Math.min(boxEnd, contentStart + 4);
|
||||||
|
else if (box.type === "stsd")
|
||||||
|
childStart = Math.min(boxEnd, contentStart + 8);
|
||||||
|
if (childStart < boxEnd)
|
||||||
|
scanBoxes(childStart, boxEnd);
|
||||||
|
}
|
||||||
|
offset = boxEnd;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
scanBoxes(0, view.byteLength);
|
||||||
|
if (sampleRate <= 0)
|
||||||
|
sampleRate = 44100;
|
||||||
|
if (channels <= 0)
|
||||||
|
channels = 2;
|
||||||
|
if (bitsPerSample <= 0)
|
||||||
|
bitsPerSample = 16;
|
||||||
|
return {
|
||||||
|
fileType: "M4A",
|
||||||
|
sampleRate,
|
||||||
|
channels,
|
||||||
|
bitsPerSample,
|
||||||
|
totalSamples: duration > 0 ? Math.floor(duration * sampleRate) : 0,
|
||||||
|
duration,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function parseAudioMetadata(input: AudioArrayBufferInput): ParsedAudioMetadata {
|
||||||
|
const fileType = detectAudioFileType(input.arrayBuffer, input.fileName);
|
||||||
|
switch (fileType) {
|
||||||
|
case "FLAC": return parseFlacMetadata(input.arrayBuffer);
|
||||||
|
case "MP3": return parseMp3Metadata(input.arrayBuffer);
|
||||||
|
case "M4A": return parseM4aMetadata(input.arrayBuffer);
|
||||||
|
case "AAC": return parseAacMetadata(input.arrayBuffer);
|
||||||
|
default: throw new Error(`Unsupported audio format: ${input.fileName || "unknown"}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function buildWindowCoefficients(size: number, windowFunction: SpectrumParams["windowFunction"]): Float32Array {
|
||||||
|
const coeffs = new Float32Array(size);
|
||||||
|
if (size <= 1) {
|
||||||
|
coeffs.fill(1);
|
||||||
|
return coeffs;
|
||||||
|
}
|
||||||
|
for (let i = 0; i < size; i++) {
|
||||||
|
switch (windowFunction) {
|
||||||
|
case "hamming":
|
||||||
|
coeffs[i] = 0.54 - 0.46 * Math.cos((2 * Math.PI * i) / (size - 1));
|
||||||
|
break;
|
||||||
|
case "blackman":
|
||||||
|
coeffs[i] =
|
||||||
|
0.42 -
|
||||||
|
0.5 * Math.cos((2 * Math.PI * i) / (size - 1)) +
|
||||||
|
0.08 * Math.cos((4 * Math.PI * i) / (size - 1));
|
||||||
|
break;
|
||||||
|
case "rectangular":
|
||||||
|
coeffs[i] = 1;
|
||||||
|
break;
|
||||||
|
case "hann":
|
||||||
|
default:
|
||||||
|
coeffs[i] = 0.5 * (1 - Math.cos((2 * Math.PI * i) / (size - 1)));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return coeffs;
|
||||||
|
}
|
||||||
|
function buildBitReversal(size: number): Uint32Array {
|
||||||
|
let bits = 0;
|
||||||
|
while ((1 << bits) < size)
|
||||||
|
bits++;
|
||||||
|
const out = new Uint32Array(size);
|
||||||
|
for (let i = 0; i < size; i++) {
|
||||||
|
let x = i;
|
||||||
|
let rev = 0;
|
||||||
|
for (let b = 0; b < bits; b++) {
|
||||||
|
rev = (rev << 1) | (x & 1);
|
||||||
|
x >>= 1;
|
||||||
|
}
|
||||||
|
out[i] = rev;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
function fftInPlace(real: Float32Array, imag: Float32Array, bitReversal: Uint32Array): void {
|
||||||
|
const size = real.length;
|
||||||
|
for (let i = 1; i < size; i++) {
|
||||||
|
const j = bitReversal[i];
|
||||||
|
if (i < j) {
|
||||||
|
const tr = real[i];
|
||||||
|
real[i] = real[j];
|
||||||
|
real[j] = tr;
|
||||||
|
const ti = imag[i];
|
||||||
|
imag[i] = imag[j];
|
||||||
|
imag[j] = ti;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let len = 2; len <= size; len <<= 1) {
|
||||||
|
const wLen = (-2 * Math.PI) / len;
|
||||||
|
const wLenReal = Math.cos(wLen);
|
||||||
|
const wLenImag = Math.sin(wLen);
|
||||||
|
for (let i = 0; i < size; i += len) {
|
||||||
|
let wReal = 1;
|
||||||
|
let wImag = 0;
|
||||||
|
const half = len >> 1;
|
||||||
|
for (let j = 0; j < half; j++) {
|
||||||
|
const uReal = real[i + j];
|
||||||
|
const uImag = imag[i + j];
|
||||||
|
const vReal = real[i + j + half] * wReal - imag[i + j + half] * wImag;
|
||||||
|
const vImag = real[i + j + half] * wImag + imag[i + j + half] * wReal;
|
||||||
|
real[i + j] = uReal + vReal;
|
||||||
|
imag[i + j] = uImag + vImag;
|
||||||
|
real[i + j + half] = uReal - vReal;
|
||||||
|
imag[i + j + half] = uImag - vImag;
|
||||||
|
const tempReal = wReal * wLenReal - wImag * wLenImag;
|
||||||
|
wImag = wReal * wLenImag + wImag * wLenReal;
|
||||||
|
wReal = tempReal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export async function analyzeSpectrumFromSamples(samples: Float32Array, sampleRate: number, params: SpectrumParams, onProgress?: AnalysisProgressCallback, shouldCancel?: AnalysisCancelCheck): Promise<SpectrumData> {
|
||||||
|
throwIfCancelled(shouldCancel);
|
||||||
|
const fftSize = params.fftSize;
|
||||||
|
const hopSize = Math.max(1, Math.floor(fftSize / 4));
|
||||||
|
const rawWindows = Math.floor((samples.length - fftSize) / hopSize);
|
||||||
|
const numWindows = Math.max(1, rawWindows);
|
||||||
|
const frameStride = Math.max(1, Math.ceil(numWindows / MAX_SPECTRUM_FRAMES));
|
||||||
|
const freqBins = Math.floor(fftSize / 2) + 1;
|
||||||
|
const duration = sampleRate > 0 ? samples.length / sampleRate : 0;
|
||||||
|
const maxFreq = sampleRate / 2;
|
||||||
|
const windowCoeffs = buildWindowCoefficients(fftSize, params.windowFunction);
|
||||||
|
const bitReversal = buildBitReversal(fftSize);
|
||||||
|
const real = new Float32Array(fftSize);
|
||||||
|
const imag = new Float32Array(fftSize);
|
||||||
|
const invFFTSizeSquared = 1 / (fftSize * fftSize);
|
||||||
|
reportProgress(onProgress, "spectrum", 0, "Preparing FFT...");
|
||||||
|
const windowIndices: number[] = [];
|
||||||
|
for (let windowIndex = 0; windowIndex < numWindows; windowIndex += frameStride) {
|
||||||
|
windowIndices.push(windowIndex);
|
||||||
|
}
|
||||||
|
if (windowIndices[windowIndices.length - 1] !== numWindows - 1) {
|
||||||
|
windowIndices.push(numWindows - 1);
|
||||||
|
}
|
||||||
|
const totalSlices = windowIndices.length;
|
||||||
|
const timeSlices: TimeSlice[] = new Array(totalSlices);
|
||||||
|
let lastReportedPercent = -1;
|
||||||
|
let lastYieldAt = nowMs();
|
||||||
|
for (let i = 0; i < totalSlices; i++) {
|
||||||
|
throwIfCancelled(shouldCancel);
|
||||||
|
const windowIndex = windowIndices[i];
|
||||||
|
const start = windowIndex * hopSize;
|
||||||
|
const remaining = samples.length - start;
|
||||||
|
const copyLen = Math.max(0, Math.min(fftSize, remaining));
|
||||||
|
for (let j = 0; j < copyLen; j++) {
|
||||||
|
real[j] = samples[start + j] * windowCoeffs[j];
|
||||||
|
imag[j] = 0;
|
||||||
|
}
|
||||||
|
for (let j = copyLen; j < fftSize; j++) {
|
||||||
|
real[j] = 0;
|
||||||
|
imag[j] = 0;
|
||||||
|
}
|
||||||
|
fftInPlace(real, imag, bitReversal);
|
||||||
|
const magnitudes = new Float32Array(freqBins);
|
||||||
|
for (let j = 0; j < freqBins; j++) {
|
||||||
|
const power = (real[j] * real[j] + imag[j] * imag[j]) * invFFTSizeSquared;
|
||||||
|
magnitudes[j] = power > 1e-12 ? 10 * Math.log10(power) : -120;
|
||||||
|
}
|
||||||
|
timeSlices[i] = {
|
||||||
|
time: sampleRate > 0 ? start / sampleRate : 0,
|
||||||
|
magnitudes,
|
||||||
|
};
|
||||||
|
const currentPercent = Math.floor(((i + 1) / totalSlices) * 100);
|
||||||
|
if (currentPercent > lastReportedPercent) {
|
||||||
|
lastReportedPercent = currentPercent;
|
||||||
|
reportProgress(onProgress, "spectrum", currentPercent, "Analyzing spectrum...");
|
||||||
|
}
|
||||||
|
if ((i + 1) % 8 === 0) {
|
||||||
|
const now = nowMs();
|
||||||
|
if (now - lastYieldAt >= 16) {
|
||||||
|
await nextTick();
|
||||||
|
lastYieldAt = nowMs();
|
||||||
|
throwIfCancelled(shouldCancel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reportProgress(onProgress, "spectrum", 100, "Spectrum analysis complete");
|
||||||
|
return {
|
||||||
|
time_slices: timeSlices,
|
||||||
|
sample_rate: sampleRate,
|
||||||
|
freq_bins: freqBins,
|
||||||
|
duration,
|
||||||
|
max_freq: maxFreq,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function createAnalysisAudioContext(sampleRate: number): AudioContext {
|
||||||
|
if (sampleRate > 0) {
|
||||||
|
try {
|
||||||
|
return new AudioContext({ sampleRate });
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return new AudioContext();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new AudioContext();
|
||||||
|
}
|
||||||
|
export async function analyzeAudioFile(file: File, params: SpectrumParams = DEFAULT_PARAMS, onProgress?: AnalysisProgressCallback, shouldCancel?: AnalysisCancelCheck): Promise<FrontendAnalysisPayload> {
|
||||||
|
throwIfCancelled(shouldCancel);
|
||||||
|
reportProgress(onProgress, "read", 2, "Reading file...");
|
||||||
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
throwIfCancelled(shouldCancel);
|
||||||
|
reportProgress(onProgress, "read", 10, "File loaded");
|
||||||
|
return analyzeAudioArrayBuffer({
|
||||||
|
fileName: file.name,
|
||||||
|
fileSize: file.size,
|
||||||
|
arrayBuffer,
|
||||||
|
}, params, (progress) => {
|
||||||
|
const mappedPercent = 10 + (progress.percent * 0.9);
|
||||||
|
reportProgress(onProgress, progress.phase, mappedPercent, progress.message);
|
||||||
|
}, shouldCancel);
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
throwIfCancelled(shouldCancel);
|
||||||
|
reportProgress(onProgress, "decode", 15, "Decoding audio stream...");
|
||||||
|
const audioContext = createAnalysisAudioContext(metadata.sampleRate);
|
||||||
|
try {
|
||||||
|
const audioBuffer = await audioContext.decodeAudioData(input.arrayBuffer.slice(0));
|
||||||
|
throwIfCancelled(shouldCancel);
|
||||||
|
reportProgress(onProgress, "decode", 35, "Audio decoded");
|
||||||
|
const samples = audioBuffer.getChannelData(0);
|
||||||
|
reportProgress(onProgress, "metrics", 40, "Calculating peak/RMS...");
|
||||||
|
let peak = 0;
|
||||||
|
let sumSquares = 0;
|
||||||
|
let lastMetricsYieldAt = nowMs();
|
||||||
|
for (let i = 0; i < samples.length; i++) {
|
||||||
|
throwIfCancelled(shouldCancel);
|
||||||
|
const sample = samples[i];
|
||||||
|
const absSample = Math.abs(sample);
|
||||||
|
if (absSample > peak)
|
||||||
|
peak = absSample;
|
||||||
|
sumSquares += sample * sample;
|
||||||
|
if ((i + 1) % METRICS_CHUNK_SIZE === 0 || i === samples.length - 1) {
|
||||||
|
const metricsProgress = 40 + (((i + 1) / samples.length) * 10);
|
||||||
|
reportProgress(onProgress, "metrics", metricsProgress, "Calculating peak/RMS...");
|
||||||
|
const now = nowMs();
|
||||||
|
if (now - lastMetricsYieldAt >= 16) {
|
||||||
|
await nextTick();
|
||||||
|
lastMetricsYieldAt = nowMs();
|
||||||
|
throwIfCancelled(shouldCancel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const peakDB = peak > 0 ? 20 * Math.log10(peak) : -120;
|
||||||
|
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 totalSamples = metadata.totalSamples > 0
|
||||||
|
? metadata.totalSamples
|
||||||
|
: Math.floor(duration * metadata.sampleRate);
|
||||||
|
reportProgress(onProgress, "metrics", 50, "Signal metrics complete");
|
||||||
|
const spectrum = await analyzeSpectrumFromSamples(samples, metadata.sampleRate, params, (progress) => {
|
||||||
|
const mappedPercent = 50 + (progress.percent * 0.45);
|
||||||
|
reportProgress(onProgress, "spectrum", mappedPercent, progress.message);
|
||||||
|
}, shouldCancel);
|
||||||
|
reportProgress(onProgress, "finalize", 97, "Finalizing result...");
|
||||||
|
const payload: FrontendAnalysisPayload = {
|
||||||
|
result: {
|
||||||
|
file_path: input.fileName,
|
||||||
|
file_size: input.fileSize,
|
||||||
|
file_type: metadata.fileType,
|
||||||
|
sample_rate: metadata.sampleRate,
|
||||||
|
channels: metadata.channels || audioBuffer.numberOfChannels,
|
||||||
|
bits_per_sample: metadata.bitsPerSample,
|
||||||
|
total_samples: totalSamples,
|
||||||
|
duration,
|
||||||
|
bit_depth: `${metadata.bitsPerSample}-bit`,
|
||||||
|
dynamic_range: dynamicRange,
|
||||||
|
peak_amplitude: peakDB,
|
||||||
|
rms_level: rmsDB,
|
||||||
|
codec_mode: metadata.codecMode,
|
||||||
|
bitrate_kbps: metadata.bitrateKbps,
|
||||||
|
total_frames: metadata.totalFrames,
|
||||||
|
codec_version: metadata.codecVersion,
|
||||||
|
spectrum,
|
||||||
|
},
|
||||||
|
samples,
|
||||||
|
};
|
||||||
|
reportProgress(onProgress, "finalize", 100, "Analysis complete");
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
await audioContext.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export const analyzeFlacFile = analyzeAudioFile;
|
||||||
|
export const analyzeFlacArrayBuffer = analyzeAudioArrayBuffer;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export const SPOTIFY_PREVIEW_VOLUME = 1;
|
||||||
@@ -112,7 +112,7 @@ export const DEFAULT_SETTINGS: Settings = {
|
|||||||
autoQuality: "16",
|
autoQuality: "16",
|
||||||
allowFallback: true,
|
allowFallback: true,
|
||||||
useSpotFetchAPI: false,
|
useSpotFetchAPI: false,
|
||||||
spotFetchAPIUrl: "https://spotify.afkarxyz.fun/api",
|
spotFetchAPIUrl: "https://sp.afkarxyz.qzz.io/api",
|
||||||
createPlaylistFolder: true,
|
createPlaylistFolder: true,
|
||||||
createM3u8File: false,
|
createM3u8File: false,
|
||||||
useFirstArtistOnly: false,
|
useFirstArtistOnly: false,
|
||||||
|
|||||||
@@ -5,41 +5,49 @@ import { getSettings } from "./settings";
|
|||||||
const toastStyle = {
|
const toastStyle = {
|
||||||
className: "font-mono lowercase",
|
className: "font-mono lowercase",
|
||||||
};
|
};
|
||||||
|
type ToastData = Parameters<typeof toast.success>[1];
|
||||||
const isSfxEnabled = () => getSettings().sfxEnabled;
|
const isSfxEnabled = () => getSettings().sfxEnabled;
|
||||||
export const toastWithSound = {
|
export const toastWithSound = {
|
||||||
success: (message: string, data?: any) => {
|
success: (message: string, data?: ToastData) => {
|
||||||
const msg = message.toLowerCase();
|
const msg = message.toLowerCase();
|
||||||
logger.success(msg);
|
logger.success(msg);
|
||||||
if (isSfxEnabled())
|
if (isSfxEnabled())
|
||||||
playSuccessSound();
|
playSuccessSound();
|
||||||
return toast.success(msg, { ...toastStyle, ...data });
|
return toast.success(msg, { ...toastStyle, ...data });
|
||||||
},
|
},
|
||||||
error: (message: string, data?: any) => {
|
error: (message: string, data?: ToastData) => {
|
||||||
const msg = message.toLowerCase();
|
const msg = message.toLowerCase();
|
||||||
logger.error(msg);
|
logger.error(msg);
|
||||||
if (isSfxEnabled())
|
if (isSfxEnabled())
|
||||||
playErrorSound();
|
playErrorSound();
|
||||||
return toast.error(msg, { ...toastStyle, ...data });
|
return toast.error(msg, { ...toastStyle, ...data });
|
||||||
},
|
},
|
||||||
warning: (message: string, data?: any) => {
|
warning: (message: string, data?: ToastData) => {
|
||||||
const msg = message.toLowerCase();
|
const msg = message.toLowerCase();
|
||||||
logger.warning(msg);
|
logger.warning(msg);
|
||||||
if (isSfxEnabled())
|
if (isSfxEnabled())
|
||||||
playWarningSound();
|
playWarningSound();
|
||||||
return toast.warning(msg, { ...toastStyle, ...data });
|
return toast.warning(msg, { ...toastStyle, ...data });
|
||||||
},
|
},
|
||||||
info: (message: string, data?: any) => {
|
info: (message: string, data?: ToastData) => {
|
||||||
const msg = message.toLowerCase();
|
const msg = message.toLowerCase();
|
||||||
logger.info(msg);
|
logger.info(msg);
|
||||||
if (isSfxEnabled())
|
if (isSfxEnabled())
|
||||||
playInfoSound();
|
playInfoSound();
|
||||||
return toast.info(msg, { ...toastStyle, ...data });
|
return toast.info(msg, { ...toastStyle, ...data });
|
||||||
},
|
},
|
||||||
message: (message: string, data?: any) => {
|
message: (message: string, data?: ToastData) => {
|
||||||
const msg = message.toLowerCase();
|
const msg = message.toLowerCase();
|
||||||
logger.info(msg);
|
logger.info(msg);
|
||||||
if (isSfxEnabled())
|
if (isSfxEnabled())
|
||||||
playInfoSound();
|
playInfoSound();
|
||||||
return toast(msg, { ...toastStyle, ...data });
|
return toast(msg, { ...toastStyle, ...data });
|
||||||
},
|
},
|
||||||
|
silentInfo: (message: string, data?: ToastData) => {
|
||||||
|
const msg = message.toLowerCase();
|
||||||
|
logger.info(msg);
|
||||||
|
return toast.info(msg, { ...toastStyle, ...data });
|
||||||
|
},
|
||||||
|
dismiss: (id?: string | number) => toast.dismiss(id),
|
||||||
|
toast: toast,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ export interface HealthResponse {
|
|||||||
}
|
}
|
||||||
export interface TimeSlice {
|
export interface TimeSlice {
|
||||||
time: number;
|
time: number;
|
||||||
magnitudes: number[];
|
magnitudes: number[] | Float32Array;
|
||||||
}
|
}
|
||||||
export interface SpectrumData {
|
export interface SpectrumData {
|
||||||
time_slices: TimeSlice[];
|
time_slices: TimeSlice[];
|
||||||
@@ -167,6 +167,7 @@ export interface SpectrumData {
|
|||||||
export interface AnalysisResult {
|
export interface AnalysisResult {
|
||||||
file_path: string;
|
file_path: string;
|
||||||
file_size: number;
|
file_size: number;
|
||||||
|
file_type?: "FLAC" | "MP3" | "M4A" | "AAC";
|
||||||
sample_rate: number;
|
sample_rate: number;
|
||||||
channels: number;
|
channels: number;
|
||||||
bits_per_sample: number;
|
bits_per_sample: number;
|
||||||
@@ -176,6 +177,10 @@ export interface AnalysisResult {
|
|||||||
dynamic_range: number;
|
dynamic_range: number;
|
||||||
peak_amplitude: number;
|
peak_amplitude: number;
|
||||||
rms_level: number;
|
rms_level: number;
|
||||||
|
codec_mode?: string;
|
||||||
|
bitrate_kbps?: number;
|
||||||
|
total_frames?: number;
|
||||||
|
codec_version?: string;
|
||||||
spectrum?: SpectrumData;
|
spectrum?: SpectrumData;
|
||||||
}
|
}
|
||||||
export interface LyricsDownloadRequest {
|
export interface LyricsDownloadRequest {
|
||||||
|
|||||||
@@ -7,11 +7,11 @@ require (
|
|||||||
github.com/go-flac/flacpicture v0.3.0
|
github.com/go-flac/flacpicture v0.3.0
|
||||||
github.com/go-flac/flacvorbis v0.2.0
|
github.com/go-flac/flacvorbis v0.2.0
|
||||||
github.com/go-flac/go-flac v1.0.0
|
github.com/go-flac/go-flac v1.0.0
|
||||||
github.com/mewkiz/flac v1.0.13
|
|
||||||
github.com/pquerna/otp v1.5.0
|
github.com/pquerna/otp v1.5.0
|
||||||
github.com/ulikunitz/xz v0.5.15
|
github.com/ulikunitz/xz v0.5.15
|
||||||
github.com/wailsapp/wails/v2 v2.11.0
|
github.com/wailsapp/wails/v2 v2.11.0
|
||||||
go.etcd.io/bbolt v1.4.3
|
go.etcd.io/bbolt v1.4.3
|
||||||
|
golang.org/x/image v0.12.0
|
||||||
golang.org/x/text v0.31.0
|
golang.org/x/text v0.31.0
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -22,7 +22,6 @@ require (
|
|||||||
github.com/godbus/dbus/v5 v5.2.0 // indirect
|
github.com/godbus/dbus/v5 v5.2.0 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/gorilla/websocket v1.5.3 // indirect
|
github.com/gorilla/websocket v1.5.3 // indirect
|
||||||
github.com/icza/bitio v1.1.0 // indirect
|
|
||||||
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
|
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
|
||||||
github.com/labstack/echo/v4 v4.13.4 // indirect
|
github.com/labstack/echo/v4 v4.13.4 // indirect
|
||||||
github.com/labstack/gommon v0.4.2 // indirect
|
github.com/labstack/gommon v0.4.2 // indirect
|
||||||
@@ -32,8 +31,6 @@ require (
|
|||||||
github.com/leaanthony/u v1.1.1 // indirect
|
github.com/leaanthony/u v1.1.1 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d // indirect
|
|
||||||
github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985 // indirect
|
|
||||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
|
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
|
||||||
github.com/pkg/errors v0.9.1 // indirect
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
|
|||||||
@@ -21,10 +21,6 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
|||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/icza/bitio v1.1.0 h1:ysX4vtldjdi3Ygai5m1cWy4oLkhWTAi+SyO6HC8L9T0=
|
|
||||||
github.com/icza/bitio v1.1.0/go.mod h1:0jGnlLAx8MKMr9VGnn/4YrvZiprkvBelsVIbA9Jjr9A=
|
|
||||||
github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6 h1:8UsGZ2rr2ksmEru6lToqnXgA8Mz1DP11X4zSJ159C3k=
|
|
||||||
github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6/go.mod h1:xQig96I1VNBDIWGCdTt54nHt6EeI639SmHycLYL7FkA=
|
|
||||||
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ=
|
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ=
|
||||||
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
|
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
|
||||||
github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA=
|
github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA=
|
||||||
@@ -48,12 +44,6 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP
|
|||||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/mewkiz/flac v1.0.13 h1:6wF8rRQKBFW159Daqx6Ro7K5ZnlVhHUKfS5aTsC4oXs=
|
|
||||||
github.com/mewkiz/flac v1.0.13/go.mod h1:HfPYDA+oxjyuqMu2V+cyKcxF51KM6incpw5eZXmfA6k=
|
|
||||||
github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d h1:IL2tii4jXLdhCeQN69HNzYYW1kl0meSG0wt5+sLwszU=
|
|
||||||
github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d/go.mod h1:SIpumAnUWSy0q9RzKD3pyH3g1t5vdawUAPcW5tQrUtI=
|
|
||||||
github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985 h1:h8O1byDZ1uk6RUXMhj1QJU3VXFKXHDZxr4TXRPGeBa8=
|
|
||||||
github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985/go.mod h1:uiPmbdUbdt1NkGApKl7htQjZ8S7XaGUAVulJUJ9v6q4=
|
|
||||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
@@ -92,15 +82,20 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
|
|||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
||||||
|
golang.org/x/image v0.12.0 h1:w13vZbU4o5rKOFFR8y7M+c4A5jXDC0uXTdHYRP8X2DQ=
|
||||||
|
golang.org/x/image v0.12.0/go.mod h1:Lu90jvHG7GfemOIcldsh9A2hS01ocl6oNO7ype5mEnk=
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
|
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
|
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
@@ -111,21 +106,26 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
|
|||||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
|
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||||
|
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
|
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
|
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@
|
|||||||
},
|
},
|
||||||
"info": {
|
"info": {
|
||||||
"productName": "SpotiFLAC",
|
"productName": "SpotiFLAC",
|
||||||
"productVersion": "7.1.1",
|
"productVersion": "7.1.2",
|
||||||
"copyright": "© 2026 afkarxyz"
|
"copyright": "© 2026 afkarxyz"
|
||||||
},
|
},
|
||||||
"wailsjsdir": "./frontend",
|
"wailsjsdir": "./frontend",
|
||||||
|
|||||||
Reference in New Issue
Block a user