This commit is contained in:
afkarxyz
2026-04-26 07:33:40 +07:00
parent 30cbcf8ab1
commit 0093df6016
33 changed files with 2174 additions and 837 deletions
+1 -1
View File
@@ -108,7 +108,7 @@ The software is provided "as is", without warranty of any kind. The author assum
## API Credits
[MusicBrainz](https://musicbrainz.org) · [LRCLIB](https://lrclib.net) · [Songlink/Odesli](https://song.link) · [hifi-api](https://github.com/binimum/hifi-api) · [dabmusic.xyz](https://dabmusic.xyz)
[MusicBrainz](https://musicbrainz.org) · [LRCLIB](https://lrclib.net) · [Songlink/Odesli](https://song.link) · [hifi-api](https://github.com/binimum/hifi-api) · [dabmusic.xyz](https://dabmusic.xyz) · [musicdl.me](https://musicdl.me)
> [!TIP]
>
+257 -80
View File
@@ -307,7 +307,6 @@ type DownloadRequest struct {
ReleaseDate string `json:"release_date,omitempty"`
CoverURL string `json:"cover_url,omitempty"`
TidalAPIURL string `json:"tidal_api_url,omitempty"`
TidalVariant string `json:"tidal_variant,omitempty"`
OutputDir string `json:"output_dir,omitempty"`
AudioFormat string `json:"audio_format,omitempty"`
FilenameFormat string `json:"filename_format,omitempty"`
@@ -508,7 +507,8 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
if req.FilenameFormat == "" {
req.FilenameFormat = "title-artist"
}
if req.ISRC == "" && strings.Contains(req.FilenameFormat, "{isrc}") && req.SpotifyID != "" {
shouldResolveISRC := strings.Contains(req.FilenameFormat, "{isrc}") || backend.GetExistingFileCheckModeSetting() == "isrc"
if req.ISRC == "" && shouldResolveISRC && req.SpotifyID != "" {
req.ISRC = backend.ResolveTrackISRC(req.SpotifyID)
}
@@ -662,11 +662,7 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
}
case "tidal":
tidalVariant := strings.ToLower(strings.TrimSpace(req.TidalVariant))
if tidalVariant == "alt" {
downloader := backend.NewTidalDownloader("")
filename, err = downloader.DownloadAlt(req.SpotifyID, req.OutputDir, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, req.Composer, metadataSeparator, req.ISRC, spotifyURL, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre)
} else if req.TidalAPIURL == "" || req.TidalAPIURL == "auto" {
if req.TidalAPIURL == "" || req.TidalAPIURL == "auto" {
downloader := backend.NewTidalDownloader("")
if req.ServiceURL != "" {
filename, err = downloader.DownloadByURLWithFallback(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, req.Composer, metadataSeparator, req.ISRC, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre)
@@ -795,9 +791,6 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
}
historySource := req.Service
if req.Service == "tidal" && strings.EqualFold(strings.TrimSpace(req.TidalVariant), "alt") {
historySource = "tidal alt"
}
go func(fPath, track, artist, album, sID, cover, format, source string) {
time.Sleep(2 * time.Second)
@@ -826,21 +819,21 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
DurationStr: durationStr,
CoverURL: cover,
Quality: quality,
Format: strings.ToUpper(format),
Path: fPath,
Source: source,
}
if item.Format == "" || item.Format == "LOSSLESS" {
ext := filepath.Ext(fPath)
if len(ext) > 1 {
item.Format = strings.ToUpper(ext[1:])
}
item.Format = strings.ToUpper(strings.TrimSpace(format))
if ext := filepath.Ext(fPath); len(ext) > 1 {
item.Format = strings.ToUpper(ext[1:])
}
switch item.Format {
case "6", "7", "27":
case "6", "7", "27", "LOSSLESS", "HI_RES", "HI_RES_LOSSLESS":
item.Format = "FLAC"
case "ALAC", "APPLE", "ATMOS", "M4A-AAC", "M4A-ALAC":
item.Format = "M4A"
}
backend.AddHistoryItem(item, "SpotiFLAC")
@@ -1029,6 +1022,90 @@ func (a *App) CheckAPIStatus(apiType string, apiURL string) bool {
return isOnline
}
func (a *App) CheckCustomTidalAPI(apiURL string) bool {
type tidalProbeResponse struct {
Version string `json:"version"`
Data struct {
TrackID int64 `json:"trackId"`
AssetPresentation string `json:"assetPresentation"`
ManifestMimeType string `json:"manifestMimeType"`
Manifest string `json:"manifest"`
} `json:"data"`
}
type tidalLegacyResponse struct {
OriginalTrackURL string `json:"OriginalTrackUrl"`
}
apiURL = strings.TrimRight(strings.TrimSpace(apiURL), "/")
if apiURL == "" {
return false
}
const probeTrackID int64 = 441821360
probeURL := fmt.Sprintf("%s/track/?id=%d&quality=LOSSLESS", apiURL, probeTrackID)
req, err := http.NewRequest(http.MethodGet, probeURL, nil)
if err != nil {
fmt.Printf("[CheckCustomTidalAPI] Failed to create request for %s: %v\n", apiURL, err)
return false
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36")
req.Header.Set("Accept", "application/json")
client := &http.Client{Timeout: 12 * time.Second}
resp, err := client.Do(req)
if err != nil {
fmt.Printf("[CheckCustomTidalAPI] Probe request failed for %s: %v\n", apiURL, err)
return false
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
if err != nil {
fmt.Printf("[CheckCustomTidalAPI] Failed to read probe response for %s: %v\n", apiURL, err)
return false
}
if resp.StatusCode != http.StatusOK {
fmt.Printf("[CheckCustomTidalAPI] Probe returned status %d for %s: %s\n", resp.StatusCode, apiURL, previewResponseBody(body, 200))
return false
}
var probe tidalProbeResponse
if err := json.Unmarshal(body, &probe); err == nil {
assetPresentation := strings.ToUpper(strings.TrimSpace(probe.Data.AssetPresentation))
switch assetPresentation {
case "FULL":
if strings.TrimSpace(probe.Data.Manifest) != "" {
fmt.Printf("[CheckCustomTidalAPI] Tidal API is ONLINE for %s (assetPresentation=%s)\n", apiURL, assetPresentation)
return true
}
fmt.Printf("[CheckCustomTidalAPI] Probe returned FULL without manifest for %s\n", apiURL)
return false
case "PREVIEW":
fmt.Printf("[CheckCustomTidalAPI] Probe returned PREVIEW for %s\n", apiURL)
return false
case "":
default:
fmt.Printf("[CheckCustomTidalAPI] Probe returned unsupported assetPresentation=%s for %s\n", assetPresentation, apiURL)
return false
}
}
var legacy []tidalLegacyResponse
if err := json.Unmarshal(body, &legacy); err == nil {
for _, item := range legacy {
if strings.TrimSpace(item.OriginalTrackURL) != "" {
fmt.Printf("[CheckCustomTidalAPI] Tidal API is ONLINE for %s (legacy response)\n", apiURL)
return true
}
}
}
fmt.Printf("[CheckCustomTidalAPI] Probe response was unusable for %s: %s\n", apiURL, previewResponseBody(body, 200))
return false
}
func buildTidalStatusCheckURLs(apiURL string) []string {
apiURL = strings.TrimRight(strings.TrimSpace(apiURL), "/")
if apiURL != "" {
@@ -1058,18 +1135,18 @@ func buildQobuzStatusCheckURLs(apiURL string) []string {
}
bases := backend.GetQobuzStreamAPIBaseURLs()
urls := make([]string, 0, len(bases))
urls := make([]string, 0, len(bases)+1)
for _, baseURL := range bases {
urls = append(urls, buildQobuzStatusCheckURL(baseURL))
}
if musicDLURL := strings.TrimSpace(backend.GetQobuzMusicDLDownloadAPIURL()); musicDLURL != "" {
urls = append(urls, musicDLURL)
}
return urls
}
func buildQobuzStatusCheckURL(apiBase string) string {
apiBase = strings.TrimSpace(apiBase)
if strings.Contains(apiBase, "qobuz.spotbye.qzz.io") {
return fmt.Sprintf("%s360735657?quality=27", apiBase)
}
return fmt.Sprintf("%s360735657&quality=27", apiBase)
}
@@ -1138,6 +1215,10 @@ func checkGroupedAPIStatus(apiType string, checkURLs []string) bool {
func checkSingleAPIStatus(apiType string, checkURL string) bool {
client := &http.Client{Timeout: 4 * time.Second}
if (apiType == "qobuz" || apiType == "qbz") && strings.EqualFold(strings.TrimSpace(checkURL), strings.TrimSpace(backend.GetQobuzMusicDLDownloadAPIURL())) {
return backend.CheckQobuzMusicDLStatus(client)
}
req, err := backend.NewRequestWithDefaultHeaders(http.MethodGet, checkURL, nil)
if err != nil {
return false
@@ -1733,6 +1814,68 @@ type CheckFileExistenceResult struct {
ArtistName string `json:"artist_name,omitempty"`
}
type existingFileLookupIndex struct {
byFilename map[string]string
byISRC map[string]string
}
func isAudioFileForExistenceCheck(path string) bool {
switch strings.ToLower(filepath.Ext(path)) {
case ".flac", ".mp3", ".m4a":
return true
default:
return false
}
}
func normalizeExistingFileIdentifier(value string) string {
return strings.ToUpper(strings.TrimSpace(value))
}
func buildExistingFileLookupIndex(scanRoot string, mode string) existingFileLookupIndex {
index := existingFileLookupIndex{
byFilename: make(map[string]string),
byISRC: make(map[string]string),
}
scanRoot = backend.NormalizePath(scanRoot)
if scanRoot == "" {
return index
}
_ = filepath.Walk(scanRoot, func(path string, info os.FileInfo, err error) error {
if err != nil || info == nil || info.IsDir() || !isAudioFileForExistenceCheck(path) {
return nil
}
if info.Size() <= 100*1024 {
return nil
}
if _, exists := index.byFilename[info.Name()]; !exists {
index.byFilename[info.Name()] = path
}
if mode == "filename" {
return nil
}
metadata, metadataErr := backend.ExtractFullMetadataFromFile(path)
if metadataErr != nil {
return nil
}
if normalizedISRC := normalizeExistingFileIdentifier(metadata.ISRC); normalizedISRC != "" {
if _, exists := index.byISRC[normalizedISRC]; !exists {
index.byISRC[normalizedISRC] = path
}
}
return nil
})
return index
}
func (a *App) CheckFilesExistence(outputDir string, rootDir string, tracks []CheckFileExistenceRequest) []CheckFileExistenceResult {
if len(tracks) == 0 {
return []CheckFileExistenceResult{}
@@ -1745,6 +1888,11 @@ func (a *App) CheckFilesExistence(outputDir string, rootDir string, tracks []Che
defaultFilenameFormat := "title-artist"
redownloadWithSuffix := backend.GetRedownloadWithSuffixSetting()
existingFileCheckMode := backend.GetExistingFileCheckModeSetting()
scanRoot := outputDir
if rootDir != "" {
scanRoot = rootDir
}
type result struct {
index int
@@ -1752,29 +1900,13 @@ func (a *App) CheckFilesExistence(outputDir string, rootDir string, tracks []Che
}
resultsChan := make(chan result, len(tracks))
var rootDirFiles map[string]string
rootDirFilesOnce := false
getRootDirFiles := func() map[string]string {
if rootDirFilesOnce {
return rootDirFiles
}
rootDirFiles = make(map[string]string)
if rootDir != "" && rootDir != outputDir {
filepath.Walk(rootDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if !info.IsDir() {
if strings.EqualFold(filepath.Ext(path), ".flac") || strings.EqualFold(filepath.Ext(path), ".mp3") {
rootDirFiles[info.Name()] = path
}
}
return nil
})
}
rootDirFilesOnce = true
return rootDirFiles
var lookupIndex existingFileLookupIndex
var lookupIndexOnce sync.Once
getLookupIndex := func() existingFileLookupIndex {
lookupIndexOnce.Do(func() {
lookupIndex = buildExistingFileLookupIndex(scanRoot, existingFileCheckMode)
})
return lookupIndex
}
for i, track := range tracks {
@@ -1796,7 +1928,8 @@ func (a *App) CheckFilesExistence(outputDir string, rootDir string, tracks []Che
filenameFormat = defaultFilenameFormat
}
isrc := strings.TrimSpace(t.ISRC)
if isrc == "" && strings.Contains(filenameFormat, "{isrc}") && t.SpotifyID != "" {
shouldResolveISRC := existingFileCheckMode == "isrc" || strings.Contains(filenameFormat, "{isrc}")
if isrc == "" && shouldResolveISRC && t.SpotifyID != "" {
isrc = backend.ResolveTrackISRC(t.SpotifyID)
}
@@ -1806,8 +1939,11 @@ func (a *App) CheckFilesExistence(outputDir string, rootDir string, tracks []Che
}
fileExt := ".flac"
if t.AudioFormat == "mp3" {
switch strings.ToLower(strings.TrimSpace(t.AudioFormat)) {
case "mp3":
fileExt = ".mp3"
case "m4a", "m4a-aac", "m4a-alac", "alac", "atmos", "apple":
fileExt = ".m4a"
}
expectedFilenameBase := backend.BuildExpectedFilename(
@@ -1836,14 +1972,29 @@ func (a *App) CheckFilesExistence(outputDir string, rootDir string, tracks []Che
expectedPath := filepath.Join(targetDir, expectedFilename)
if redownloadWithSuffix {
expectedPath, _ = backend.ResolveOutputPathForDownload(expectedPath, true)
res.FilePath = filepath.Base(expectedPath)
} else {
resultsChan <- result{index: idx, result: res}
return
}
normalizedISRC := normalizeExistingFileIdentifier(isrc)
effectiveMode := existingFileCheckMode
if effectiveMode == "isrc" && normalizedISRC == "" {
effectiveMode = "filename"
}
switch effectiveMode {
case "isrc":
if path, ok := getLookupIndex().byISRC[normalizedISRC]; ok {
res.Exists = true
res.FilePath = path
}
default:
if fileInfo, err := os.Stat(expectedPath); err == nil && fileInfo.Size() > 100*1024 {
res.Exists = true
res.FilePath = expectedPath
} else {
res.FilePath = expectedFilename
} else if path, ok := getLookupIndex().byFilename[filepath.Base(expectedPath)]; ok {
res.Exists = true
res.FilePath = path
}
}
@@ -1852,39 +2003,10 @@ func (a *App) CheckFilesExistence(outputDir string, rootDir string, tracks []Che
}
results := make([]CheckFileExistenceResult, len(tracks))
missingIndices := []int{}
for i := 0; i < len(tracks); i++ {
r := <-resultsChan
results[r.index] = r.result
if !results[r.index].Exists {
missingIndices = append(missingIndices, r.index)
}
}
if len(missingIndices) > 0 && rootDir != "" {
filesMap := getRootDirFiles()
if len(filesMap) > 0 {
for _, idx := range missingIndices {
expectedFilename := results[idx].FilePath
baseName := filepath.Base(expectedFilename)
if path, ok := filesMap[baseName]; ok {
results[idx].Exists = true
results[idx].FilePath = path
} else {
results[idx].FilePath = ""
}
}
} else {
for _, idx := range missingIndices {
results[idx].FilePath = ""
}
}
} else {
for _, idx := range missingIndices {
results[idx].FilePath = ""
}
}
return results
@@ -1910,6 +2032,14 @@ func (a *App) GetConfigPath() (string, error) {
return filepath.Join(dir, "config.json"), nil
}
func (a *App) GetFontsPath() (string, error) {
dir, err := backend.GetFFmpegDir()
if err != nil {
return "", err
}
return filepath.Join(dir, "fonts.json"), nil
}
func (a *App) SaveSettings(settings map[string]interface{}) error {
configPath, err := a.GetConfigPath()
if err != nil {
@@ -1931,6 +2061,27 @@ func (a *App) SaveSettings(settings map[string]interface{}) error {
return os.WriteFile(configPath, data, 0644)
}
func (a *App) SaveFonts(fonts []map[string]interface{}) error {
fontsPath, err := a.GetFontsPath()
if err != nil {
return err
}
dir := filepath.Dir(fontsPath)
if _, err := os.Stat(dir); os.IsNotExist(err) {
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
}
data, err := json.MarshalIndent(fonts, "", " ")
if err != nil {
return err
}
return os.WriteFile(fontsPath, data, 0644)
}
func (a *App) LoadSettings() (map[string]interface{}, error) {
configPath, err := a.GetConfigPath()
if err != nil {
@@ -1954,6 +2105,32 @@ func (a *App) LoadSettings() (map[string]interface{}, error) {
return settings, nil
}
func (a *App) LoadFonts() ([]map[string]interface{}, error) {
fontsPath, err := a.GetFontsPath()
if err != nil {
return nil, err
}
if _, err := os.Stat(fontsPath); os.IsNotExist(err) {
return nil, nil
}
data, err := os.ReadFile(fontsPath)
if err != nil {
return nil, err
}
var fonts []map[string]interface{}
if err := json.Unmarshal(data, &fonts); err != nil {
return nil, err
}
if fonts == nil {
return []map[string]interface{}{}, nil
}
return fonts, nil
}
func (a *App) CheckFFmpegInstalled() (bool, error) {
return backend.IsFFmpegInstalled()
}
+80
View File
@@ -1,6 +1,9 @@
package backend
import (
"crypto/aes"
"crypto/cipher"
"crypto/sha256"
"encoding/json"
"fmt"
"io"
@@ -10,6 +13,7 @@ import (
"path/filepath"
"regexp"
"strings"
"sync"
"time"
)
@@ -23,6 +27,76 @@ type AmazonStreamResponse struct {
DecryptionKey string `json:"decryptionKey"`
}
var (
amazonMusicDebugKeyOnce sync.Once
amazonMusicDebugKey string
amazonMusicDebugKeyErr error
)
var amazonMusicDebugKeySeedParts = [][]byte{
[]byte("spotif"),
[]byte("lac:am"),
[]byte("azon:spotbye:api:v1"),
}
var amazonMusicDebugKeyAAD = []byte{
0x61, 0x6d, 0x61, 0x7a, 0x6f, 0x6e, 0x7c, 0x73, 0x70, 0x6f, 0x74, 0x62,
0x79, 0x65, 0x7c, 0x64, 0x65, 0x62, 0x75, 0x67, 0x7c, 0x76, 0x31,
}
var amazonMusicDebugKeyNonce = []byte{
0x52, 0x1f, 0xa4, 0x9c, 0x13, 0x77, 0x5b, 0xe2, 0x81, 0x44, 0x90, 0x6d,
}
var amazonMusicDebugKeyCiphertext = []byte{
0x5b, 0xf9, 0xc1, 0x2e, 0x58, 0xf8, 0x5b, 0xc0, 0x04, 0x68, 0x7e, 0xff,
0x3d, 0xd6, 0x8b, 0xe3, 0x86, 0x49, 0x6c, 0xfd, 0xc1, 0x49, 0x0b, 0xfb,
}
var amazonMusicDebugKeyTag = []byte{
0x6c, 0x21, 0x98, 0x51, 0xf2, 0x38, 0x4b, 0x4a, 0x23, 0xe1, 0xc6, 0xd7,
0x65, 0x7f, 0xfb, 0xa1,
}
func getAmazonMusicDebugKey() (string, error) {
amazonMusicDebugKeyOnce.Do(func() {
hasher := sha256.New()
for _, part := range amazonMusicDebugKeySeedParts {
hasher.Write(part)
}
block, err := aes.NewCipher(hasher.Sum(nil))
if err != nil {
amazonMusicDebugKeyErr = err
return
}
gcm, err := cipher.NewGCM(block)
if err != nil {
amazonMusicDebugKeyErr = err
return
}
sealed := make([]byte, 0, len(amazonMusicDebugKeyCiphertext)+len(amazonMusicDebugKeyTag))
sealed = append(sealed, amazonMusicDebugKeyCiphertext...)
sealed = append(sealed, amazonMusicDebugKeyTag...)
plaintext, err := gcm.Open(nil, amazonMusicDebugKeyNonce, sealed, amazonMusicDebugKeyAAD)
if err != nil {
amazonMusicDebugKeyErr = err
return
}
amazonMusicDebugKey = string(plaintext)
})
if amazonMusicDebugKeyErr != nil {
return "", amazonMusicDebugKeyErr
}
return amazonMusicDebugKey, nil
}
func NewAmazonDownloader() *AmazonDownloader {
return &AmazonDownloader{
client: &http.Client{
@@ -62,6 +136,12 @@ func (a *AmazonDownloader) DownloadFromAfkarXYZ(amazonURL, outputDir, quality st
return "", err
}
debugKey, err := getAmazonMusicDebugKey()
if err != nil {
return "", fmt.Errorf("failed to decrypt Amazon debug key: %w", err)
}
req.Header.Set("X-Debug-Key", debugKey)
fmt.Printf("Fetching from Amazon API (ASIN: %s)...\n", asin)
resp, err := a.client.Do(req)
if err != nil {
+34
View File
@@ -60,6 +60,40 @@ func GetRedownloadWithSuffixSetting() bool {
return enabled
}
func GetCustomTidalAPISetting() string {
settings, err := LoadConfigSettings()
if err != nil || settings == nil {
return ""
}
customAPI, _ := settings["customTidalApi"].(string)
customAPI = strings.TrimRight(strings.TrimSpace(customAPI), "/")
if strings.HasPrefix(customAPI, "https://") {
return customAPI
}
return ""
}
func normalizeExistingFileCheckMode(value string) string {
switch strings.TrimSpace(strings.ToLower(value)) {
case "isrc", "upc":
return "isrc"
default:
return "filename"
}
}
func GetExistingFileCheckModeSetting() string {
settings, err := LoadConfigSettings()
if err != nil || settings == nil {
return "filename"
}
rawMode, _ := settings["existingFileCheckMode"].(string)
return normalizeExistingFileCheckMode(rawMode)
}
func GetLinkResolverSetting() string {
settings, err := LoadConfigSettings()
if err != nil || settings == nil {
+189 -52
View File
@@ -19,6 +19,11 @@ import (
"golang.org/x/text/unicode/norm"
)
type executableCandidate struct {
path string
source string
}
func ValidateExecutable(path string) error {
cleanedPath := filepath.Clean(path)
if cleanedPath == "" {
@@ -83,6 +88,50 @@ func GetFFmpegDir() (string, error) {
return EnsureAppDir()
}
func copyExecutable(src, dst string) error {
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.OpenFile(dst, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o755)
if err != nil {
return err
}
defer out.Close()
if _, err = io.Copy(out, in); err != nil {
return err
}
if err := out.Sync(); err != nil {
return err
}
return prepareExecutableForUse(dst)
}
func appendExecutableCandidate(candidates []executableCandidate, seen map[string]struct{}, path, source string) []executableCandidate {
cleanedPath := filepath.Clean(strings.TrimSpace(path))
if cleanedPath == "" {
return candidates
}
if _, exists := seen[cleanedPath]; exists {
return candidates
}
seen[cleanedPath] = struct{}{}
return append(candidates, executableCandidate{
path: cleanedPath,
source: source,
})
}
func resolveSystemExecutable(executableName string) string {
if runtime.GOOS == "darwin" {
candidates := []string{
@@ -114,83 +163,163 @@ func resolveSystemExecutable(executableName string) string {
return ""
}
func GetFFmpegPath() (string, error) {
ffmpegDir, err := GetFFmpegDir()
if err != nil {
return "", err
func runExecutableVersionCheck(path string) error {
cmd := exec.Command(path, "-version")
setHideWindow(cmd)
return cmd.Run()
}
func removeMacOSQuarantineAttribute(path string) error {
cmd := exec.Command("xattr", "-d", "com.apple.quarantine", path)
setHideWindow(cmd)
output, err := cmd.CombinedOutput()
if err == nil {
return nil
}
trimmedOutput := strings.TrimSpace(string(output))
lowerOutput := strings.ToLower(trimmedOutput)
if strings.Contains(lowerOutput, "no such xattr") || strings.Contains(lowerOutput, "attribute not found") {
return nil
}
if trimmedOutput != "" {
return fmt.Errorf("%w: %s", err, trimmedOutput)
}
return err
}
func prepareExecutableForUse(path string) error {
cleanedPath := filepath.Clean(strings.TrimSpace(path))
if cleanedPath == "" {
return fmt.Errorf("empty path")
}
if runtime.GOOS == "windows" {
return nil
}
if err := os.Chmod(cleanedPath, 0755); err != nil {
return fmt.Errorf("failed to mark executable: %w", err)
}
if runtime.GOOS == "darwin" {
if err := removeMacOSQuarantineAttribute(cleanedPath); err != nil {
fmt.Printf("[FFmpeg] Warning: failed to remove macOS quarantine from %s: %v\n", cleanedPath, err)
}
}
return nil
}
func resolveExecutablePath(executableName string) (string, string, error) {
ffmpegDir, err := GetFFmpegDir()
if err != nil {
return "", "", err
}
localPath := filepath.Join(ffmpegDir, executableName)
nextDir := filepath.Join(filepath.Dir(ffmpegDir), ".spotiflac-next")
nextPath := filepath.Join(nextDir, executableName)
localExists := false
candidates := make([]executableCandidate, 0, 3)
seen := make(map[string]struct{}, 3)
if systemPath := resolveSystemExecutable(executableName); systemPath != "" {
candidates = appendExecutableCandidate(candidates, seen, systemPath, "system")
}
if _, err := os.Stat(localPath); err == nil {
localExists = true
candidates = appendExecutableCandidate(candidates, seen, localPath, "local")
}
if !localExists {
if _, err := os.Stat(nextPath); err == nil {
if copyErr := copyExecutable(nextPath, localPath); copyErr == nil {
fmt.Printf("[FFmpeg] Copied %s from SpotiFLAC-Next folder\n", executableName)
candidates = appendExecutableCandidate(candidates, seen, localPath, "migrated")
}
}
}
var lastErr error
for _, candidate := range candidates {
if candidate.source != "system" {
if err := prepareExecutableForUse(candidate.path); err != nil {
lastErr = err
fmt.Printf("[FFmpeg] Skipping %s %s: %v\n", candidate.source, candidate.path, err)
continue
}
}
if err := ValidateExecutable(candidate.path); err != nil {
lastErr = err
fmt.Printf("[FFmpeg] Skipping %s %s: %v\n", candidate.source, candidate.path, err)
continue
}
if err := runExecutableVersionCheck(candidate.path); err != nil {
lastErr = err
fmt.Printf("[FFmpeg] Skipping %s %s: %v\n", candidate.source, candidate.path, err)
continue
}
return candidate.path, localPath, nil
}
if len(candidates) > 0 {
if lastErr != nil {
return "", localPath, fmt.Errorf("no working %s executable found: %w", executableName, lastErr)
}
return "", localPath, fmt.Errorf("no working %s executable found", executableName)
}
return "", localPath, fmt.Errorf("%s not found in app directory or system path", executableName)
}
func GetFFmpegPath() (string, error) {
ffmpegName := "ffmpeg"
if runtime.GOOS == "windows" {
ffmpegName = "ffmpeg.exe"
}
if path := resolveSystemExecutable(ffmpegName); path != "" {
return path, nil
}
localPath := filepath.Join(ffmpegDir, ffmpegName)
if _, err := os.Stat(localPath); err == nil {
return localPath, nil
}
return localPath, nil
}
func GetFFprobePath() (string, error) {
ffmpegDir, err := GetFFmpegDir()
path, localPath, err := resolveExecutablePath(ffmpegName)
if err != nil {
if localPath != "" {
return localPath, err
}
return "", err
}
return path, nil
}
func GetFFprobePath() (string, error) {
ffprobeName := "ffprobe"
if runtime.GOOS == "windows" {
ffprobeName = "ffprobe.exe"
}
if path := resolveSystemExecutable(ffprobeName); path != "" {
return path, nil
path, localPath, err := resolveExecutablePath(ffprobeName)
if err != nil {
if localPath != "" {
return localPath, err
}
return "", err
}
localPath := filepath.Join(ffmpegDir, ffprobeName)
if _, err := os.Stat(localPath); err == nil {
return localPath, nil
}
return localPath, fmt.Errorf("ffprobe not found in app directory or system path")
return path, nil
}
func IsFFprobeInstalled() (bool, error) {
ffprobePath, err := GetFFprobePath()
if err != nil {
return false, nil
}
if err := ValidateExecutable(ffprobePath); err != nil {
return false, nil
}
cmd := exec.Command(ffprobePath, "-version")
setHideWindow(cmd)
err = cmd.Run()
_, err := GetFFprobePath()
return err == nil, nil
}
func IsFFmpegInstalled() (bool, error) {
ffmpegPath, err := GetFFmpegPath()
if err != nil {
return false, err
}
if err := ValidateExecutable(ffmpegPath); err != nil {
return false, nil
}
cmd := exec.Command(ffmpegPath, "-version")
setHideWindow(cmd)
err = cmd.Run()
if err != nil {
if _, err := GetFFmpegPath(); err != nil {
return false, nil
}
@@ -507,6 +636,10 @@ func extractZip(zipPath, destDir string) error {
return fmt.Errorf("failed to extract file: %w", err)
}
if err := prepareExecutableForUse(destPath); err != nil {
return fmt.Errorf("failed to prepare extracted executable: %w", err)
}
fmt.Printf("[FFmpeg] Extracted to: %s\n", destPath)
}
@@ -584,6 +717,10 @@ func extractTarXz(tarXzPath, destDir string) error {
return fmt.Errorf("failed to extract file: %w", err)
}
if err := prepareExecutableForUse(destPath); err != nil {
return fmt.Errorf("failed to prepare extracted executable: %w", err)
}
fmt.Printf("[FFmpeg] Extracted to: %s\n", destPath)
}
+5 -1
View File
@@ -1,17 +1,21 @@
package backend
const amazonMusicAPIBaseURL = "https://amazon.spotbye.qzz.io"
const qobuzMusicDLDownloadAPIURL = "https://www.musicdl.me/api/qobuz/download"
var defaultQobuzStreamAPIBaseURLs = []string{
"https://dab.yeet.su/api/stream?trackId=",
"https://dabmusic.xyz/api/stream?trackId=",
"https://qobuz.spotbye.qzz.io/api/track/",
}
func GetQobuzStreamAPIBaseURLs() []string {
return append([]string(nil), defaultQobuzStreamAPIBaseURLs...)
}
func GetQobuzMusicDLDownloadAPIURL() string {
return qobuzMusicDLDownloadAPIURL
}
func GetAmazonMusicAPIBaseURL() string {
return amazonMusicAPIBaseURL
}
+214 -10
View File
@@ -1,6 +1,10 @@
package backend
import (
"bytes"
"crypto/aes"
"crypto/cipher"
"crypto/sha256"
"encoding/json"
"fmt"
"io"
@@ -10,6 +14,7 @@ import (
"path/filepath"
"regexp"
"strings"
"sync"
"time"
)
@@ -68,6 +73,57 @@ type QobuzStreamResponse struct {
URL string `json:"url"`
}
type qobuzMusicDLRequest struct {
URL string `json:"url"`
Quality string `json:"quality"`
}
type qobuzMusicDLResponse struct {
Success bool `json:"success"`
Type string `json:"type"`
URLType string `json:"url_type"`
TrackID string `json:"track_id"`
Quality string `json:"quality_label"`
DownloadURL string `json:"download_url"`
Message string `json:"message"`
Error string `json:"error"`
}
const qobuzMusicDLProbeTrackID int64 = 341032040
var (
qobuzMusicDLDebugKeyOnce sync.Once
qobuzMusicDLDebugKey string
qobuzMusicDLDebugKeyErr error
)
var qobuzMusicDLDebugKeySeedParts = [][]byte{
{0x73, 0x70, 0x6f, 0x74, 0x69, 0x66},
{0x6c, 0x61, 0x63, 0x3a, 0x71, 0x6f},
{0x62, 0x75, 0x7a, 0x3a, 0x6d, 0x75, 0x73, 0x69, 0x63, 0x64, 0x6c, 0x3a, 0x76, 0x31},
}
var qobuzMusicDLDebugKeyAAD = []byte{
0x71, 0x6f, 0x62, 0x75, 0x7a, 0x7c, 0x6d, 0x75, 0x73, 0x69, 0x63, 0x64,
0x6c, 0x7c, 0x64, 0x65, 0x62, 0x75, 0x67, 0x7c, 0x76, 0x31,
}
var qobuzMusicDLDebugKeyNonce = []byte{
0x91, 0x2a, 0x5c, 0x77, 0x0f, 0x33, 0xa8, 0x14, 0x62, 0x9d, 0xce, 0x41,
}
var qobuzMusicDLDebugKeyCiphertext = []byte{
0xf3, 0x4a, 0x83, 0x45, 0x24, 0xb6, 0x22, 0xaf, 0xd6, 0xc3, 0x6e, 0x2d,
0x56, 0xd1, 0xbb, 0x0b, 0xe9, 0x1b, 0x4f, 0x1c, 0x5f, 0x41, 0x55, 0xc2,
0xc6, 0xdf, 0xad, 0x21, 0x58, 0xfe, 0xd5, 0xb8, 0x2d, 0x29, 0xf9, 0x9e,
0x6f, 0xd6,
}
var qobuzMusicDLDebugKeyTag = []byte{
0x69, 0x0c, 0x42, 0x70, 0x14, 0x83, 0xff, 0x14, 0xc8, 0xbe, 0x17, 0x00,
0x69, 0xb1, 0xfe, 0xbb,
}
func NewQobuzDownloader() *QobuzDownloader {
return &QobuzDownloader{
client: &http.Client{
@@ -77,6 +133,57 @@ func NewQobuzDownloader() *QobuzDownloader {
}
}
func previewQobuzResponseBody(body []byte, maxLen int) string {
preview := strings.TrimSpace(string(body))
if len(preview) > maxLen {
return preview[:maxLen] + "..."
}
return preview
}
func buildQobuzOpenTrackURL(trackID int64) string {
return fmt.Sprintf("https://open.qobuz.com/track/%d", trackID)
}
func getQobuzMusicDLDebugKey() (string, error) {
qobuzMusicDLDebugKeyOnce.Do(func() {
hasher := sha256.New()
for _, part := range qobuzMusicDLDebugKeySeedParts {
hasher.Write(part)
}
block, err := aes.NewCipher(hasher.Sum(nil))
if err != nil {
qobuzMusicDLDebugKeyErr = err
return
}
gcm, err := cipher.NewGCM(block)
if err != nil {
qobuzMusicDLDebugKeyErr = err
return
}
sealed := make([]byte, 0, len(qobuzMusicDLDebugKeyCiphertext)+len(qobuzMusicDLDebugKeyTag))
sealed = append(sealed, qobuzMusicDLDebugKeyCiphertext...)
sealed = append(sealed, qobuzMusicDLDebugKeyTag...)
plaintext, err := gcm.Open(nil, qobuzMusicDLDebugKeyNonce, sealed, qobuzMusicDLDebugKeyAAD)
if err != nil {
qobuzMusicDLDebugKeyErr = err
return
}
qobuzMusicDLDebugKey = string(plaintext)
})
if qobuzMusicDLDebugKeyErr != nil {
return "", qobuzMusicDLDebugKeyErr
}
return qobuzMusicDLDebugKey, nil
}
func (q *QobuzDownloader) searchByISRC(isrc string) (*QobuzTrack, error) {
if strings.HasPrefix(isrc, "qobuz_") {
trackID := strings.TrimPrefix(isrc, "qobuz_")
@@ -139,9 +246,6 @@ func (q *QobuzDownloader) searchByISRC(isrc string) (*QobuzTrack, error) {
}
func buildQobuzAPIURL(apiBase string, trackID int64, quality string) string {
if strings.Contains(apiBase, "qobuz.spotbye.qzz.io") {
return fmt.Sprintf("%s%d?quality=%s", apiBase, trackID, quality)
}
return fmt.Sprintf("%s%d&quality=%s", apiBase, trackID, quality)
}
@@ -188,6 +292,81 @@ func (q *QobuzDownloader) DownloadFromStandard(apiBase string, trackID int64, qu
return "", fmt.Errorf("invalid response")
}
func (q *QobuzDownloader) DownloadFromMusicDL(trackID int64, quality string) (string, error) {
if strings.TrimSpace(quality) == "" {
quality = "6"
}
debugKey, err := getQobuzMusicDLDebugKey()
if err != nil {
return "", fmt.Errorf("failed to decrypt MusicDL debug key: %w", err)
}
payload, err := json.Marshal(qobuzMusicDLRequest{
URL: buildQobuzOpenTrackURL(trackID),
Quality: strings.TrimSpace(quality),
})
if err != nil {
return "", fmt.Errorf("failed to encode MusicDL request: %w", err)
}
req, err := NewRequestWithDefaultHeaders(http.MethodPost, GetQobuzMusicDLDownloadAPIURL(), bytes.NewReader(payload))
if err != nil {
return "", fmt.Errorf("failed to create MusicDL request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Debug-Key", debugKey)
resp, err := q.client.Do(req)
if err != nil {
return "", fmt.Errorf("failed to reach MusicDL: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read MusicDL response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("MusicDL returned status %d: %s", resp.StatusCode, previewQobuzResponseBody(body, 256))
}
var downloadResp qobuzMusicDLResponse
if err := json.Unmarshal(body, &downloadResp); err != nil {
return "", fmt.Errorf("failed to decode MusicDL response: %w (%s)", err, previewQobuzResponseBody(body, 256))
}
if !downloadResp.Success {
message := strings.TrimSpace(downloadResp.Error)
if message == "" {
message = strings.TrimSpace(downloadResp.Message)
}
if message == "" {
message = "MusicDL reported failure"
}
return "", fmt.Errorf("%s", message)
}
downloadURL := strings.TrimSpace(downloadResp.DownloadURL)
if downloadURL == "" {
return "", fmt.Errorf("MusicDL response did not include a download_url")
}
return downloadURL, nil
}
func CheckQobuzMusicDLStatus(client *http.Client) bool {
if client == nil {
client = &http.Client{Timeout: 4 * time.Second}
}
downloader := &QobuzDownloader{client: client, appID: qobuzDefaultAPIAppID}
_, err := downloader.DownloadFromMusicDL(qobuzMusicDLProbeTrackID, "27")
return err == nil
}
func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string, allowFallback bool) (string, error) {
qualityCode := quality
if qualityCode == "" || qualityCode == "5" {
@@ -196,8 +375,6 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string, allowFal
fmt.Printf("Getting download URL for track ID: %d with requested quality: %s\n", trackID, qualityCode)
standardAPIs := prioritizeProviders("qobuz", GetQobuzStreamAPIBaseURLs())
downloadFunc := func(qual string) (string, error) {
type Provider struct {
Name string
@@ -205,21 +382,48 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string, allowFal
Func func() (string, error)
}
var providers []Provider
providerMap := make(map[string]Provider)
providerIDs := []string{GetQobuzMusicDLDownloadAPIURL()}
for _, api := range standardAPIs {
providerMap[GetQobuzMusicDLDownloadAPIURL()] = Provider{
Name: "MusicDL",
API: GetQobuzMusicDLDownloadAPIURL(),
Func: func() (string, error) {
return q.DownloadFromMusicDL(trackID, qual)
},
}
for _, api := range GetQobuzStreamAPIBaseURLs() {
currentAPI := api
providers = append(providers, Provider{
providerIDs = append(providerIDs, currentAPI)
providerMap[currentAPI] = Provider{
Name: "Standard(" + currentAPI + ")",
API: currentAPI,
Func: func() (string, error) {
return q.DownloadFromStandard(currentAPI, trackID, qual)
},
})
}
}
orderedProviderIDs := prioritizeProviders("qobuz", providerIDs)
primaryProviderID := GetQobuzMusicDLDownloadAPIURL()
if len(orderedProviderIDs) > 1 && orderedProviderIDs[0] != primaryProviderID {
reordered := []string{primaryProviderID}
for _, providerID := range orderedProviderIDs {
if providerID == primaryProviderID {
continue
}
reordered = append(reordered, providerID)
}
orderedProviderIDs = reordered
}
var lastErr error
for _, p := range providers {
for _, providerID := range orderedProviderIDs {
p, ok := providerMap[providerID]
if !ok {
continue
}
fmt.Printf("Trying Provider: %s (Quality: %s)...\n", p.Name, qual)
url, err := p.Func()
+189 -10
View File
@@ -9,6 +9,7 @@ import (
"net/http"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
@@ -47,10 +48,172 @@ type TidalBTSManifest struct {
URLs []string `json:"urls"`
}
func getConfiguredTidalAPIAttemptList() ([]string, error) {
customAPI := GetCustomTidalAPISetting()
apis, err := GetRotatedTidalAPIList()
if customAPI == "" {
return apis, err
}
if err != nil && len(apis) == 0 {
return []string{customAPI}, nil
}
result := make([]string, 0, len(apis)+1)
result = append(result, customAPI)
for _, apiURL := range apis {
apiURL = strings.TrimRight(strings.TrimSpace(apiURL), "/")
if apiURL == "" || apiURL == customAPI {
continue
}
result = append(result, apiURL)
}
return result, err
}
func buildTidalOutputPath(outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyTrackNumber, spotifyDiscNumber int, isrcOverride string, useFirstArtistOnly bool) (string, bool, error) {
if outputDir != "." {
if err := os.MkdirAll(outputDir, 0755); err != nil {
return "", false, fmt.Errorf("directory error: %w", err)
}
}
artistNameForFile := sanitizeFilename(spotifyArtistName)
albumArtistForFile := sanitizeFilename(spotifyAlbumArtist)
if useFirstArtistOnly {
artistNameForFile = sanitizeFilename(GetFirstArtist(spotifyArtistName))
albumArtistForFile = sanitizeFilename(GetFirstArtist(spotifyAlbumArtist))
}
trackTitleForFile := sanitizeFilename(spotifyTrackName)
albumTitleForFile := sanitizeFilename(spotifyAlbumName)
filename := buildTidalFilename(trackTitleForFile, artistNameForFile, albumTitleForFile, albumArtistForFile, spotifyReleaseDate, spotifyTrackNumber, spotifyDiscNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber, isrcOverride)
outputFilename := filepath.Join(outputDir, filename)
outputFilename, alreadyExists := ResolveOutputPathForDownload(outputFilename, GetRedownloadWithSuffixSetting())
return outputFilename, alreadyExists, nil
}
func finalizeTidalDownload(outputFilename, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL string, useSingleGenre bool, embedGenre bool) {
trackTitle := spotifyTrackName
artistName := spotifyArtistName
albumTitle := spotifyAlbumName
type mbResult struct {
ISRC string
Metadata Metadata
}
metaChan := make(chan mbResult, 1)
if embedGenre && spotifyURL != "" {
go func() {
res := mbResult{}
var isrc string
parts := strings.Split(spotifyURL, "/")
if len(parts) > 0 {
sID := strings.Split(parts[len(parts)-1], "?")[0]
if sID != "" {
client := NewSongLinkClient()
if val, err := client.GetISRC(sID); err == nil {
isrc = val
}
}
}
res.ISRC = isrc
if isrc != "" {
if ShouldSkipMusicBrainzMetadataFetch() {
fmt.Println("Skipping MusicBrainz metadata fetch because status check is offline.")
} else {
fmt.Println("Fetching MusicBrainz metadata...")
if fetchedMeta, err := FetchMusicBrainzMetadata(isrc, trackTitle, artistName, albumTitle, useSingleGenre, embedGenre); err == nil {
res.Metadata = fetchedMeta
fmt.Println("✓ MusicBrainz metadata fetched")
} else {
fmt.Printf("Warning: Failed to fetch MusicBrainz metadata: %v\n", err)
}
}
}
metaChan <- res
}()
} else {
close(metaChan)
}
isrc := strings.TrimSpace(isrcOverride)
var mbMeta Metadata
if spotifyURL != "" {
result := <-metaChan
if isrc == "" {
isrc = result.ISRC
}
mbMeta = result.Metadata
}
upc := ""
if spotifyURL != "" {
if identifiers, err := GetSpotifyTrackIdentifiersDirect(spotifyURL); err == nil || identifiers.ISRC != "" || identifiers.UPC != "" {
if strings.TrimSpace(isrc) == "" && strings.TrimSpace(identifiers.ISRC) != "" {
isrc = strings.TrimSpace(identifiers.ISRC)
}
upc = strings.TrimSpace(identifiers.UPC)
}
}
fmt.Println("Adding metadata...")
coverPath := ""
if spotifyCoverURL != "" {
coverPath = outputFilename + ".cover.jpg"
coverClient := NewCoverClient()
if err := coverClient.DownloadCoverToPath(spotifyCoverURL, coverPath, embedMaxQualityCover); err != nil {
fmt.Printf("Warning: Failed to download Spotify cover: %v\n", err)
coverPath = ""
} else {
defer os.Remove(coverPath)
fmt.Println("Spotify cover downloaded")
}
}
trackNumberToEmbed := spotifyTrackNumber
if trackNumberToEmbed == 0 {
trackNumberToEmbed = 1
}
metadata := Metadata{
Title: trackTitle,
Artist: artistName,
Album: albumTitle,
AlbumArtist: spotifyAlbumArtist,
Date: spotifyReleaseDate,
TrackNumber: trackNumberToEmbed,
TotalTracks: spotifyTotalTracks,
DiscNumber: spotifyDiscNumber,
TotalDiscs: spotifyTotalDiscs,
URL: spotifyURL,
Comment: spotifyURL,
Copyright: spotifyCopyright,
Publisher: spotifyPublisher,
Composer: spotifyComposer,
Separator: metadataSeparator,
Description: "https://github.com/spotbye/SpotiFLAC",
ISRC: isrc,
UPC: upc,
Genre: mbMeta.Genre,
}
if err := EmbedMetadata(outputFilename, metadata, coverPath); err != nil {
fmt.Printf("Tagging failed: %v\n", err)
} else {
fmt.Println("Metadata saved")
}
}
func NewTidalDownloader(apiURL string) *TidalDownloader {
apiURL = strings.TrimRight(strings.TrimSpace(apiURL), "/")
if apiURL == "" {
apis, err := GetRotatedTidalAPIList()
apis, err := getConfiguredTidalAPIAttemptList()
if err == nil && len(apis) > 0 {
apiURL = apis[0]
}
@@ -67,7 +230,7 @@ func NewTidalDownloader(apiURL string) *TidalDownloader {
}
func (t *TidalDownloader) GetAvailableAPIs() ([]string, error) {
apis, err := GetRotatedTidalAPIList()
apis, err := getConfiguredTidalAPIAttemptList()
if err == nil && len(apis) > 0 {
return apis, nil
}
@@ -173,10 +336,10 @@ func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (string,
return "", fmt.Errorf("download URL not found in response")
}
func (t *TidalDownloader) DownloadFile(url, filepath string) error {
func (t *TidalDownloader) DownloadFile(url, filepath string, quality string) error {
if strings.HasPrefix(url, "MANIFEST:") {
return t.DownloadFromManifest(strings.TrimPrefix(url, "MANIFEST:"), filepath)
return t.DownloadFromManifest(strings.TrimPrefix(url, "MANIFEST:"), filepath, quality)
}
req, err := NewRequestWithDefaultHeaders(http.MethodGet, url, nil)
@@ -213,12 +376,18 @@ func (t *TidalDownloader) DownloadFile(url, filepath string) error {
return nil
}
func (t *TidalDownloader) DownloadFromManifest(manifestB64, outputPath string) error {
func (t *TidalDownloader) DownloadFromManifest(manifestB64, outputPath string, quality string) error {
directURL, initURL, mediaURLs, mimeType, err := parseManifest(manifestB64)
if err != nil {
return fmt.Errorf("failed to parse manifest: %w", err)
}
isLosslessRequested := quality == "LOSSLESS" || quality == "HI_RES" || quality == "HI_RES_LOSSLESS"
isActualLossless := strings.Contains(strings.ToLower(mimeType), "flac") || mimeType == ""
if isLosslessRequested && !isActualLossless {
return fmt.Errorf("requested %s quality but Tidal provided lossy format (%s). Aborting download", quality, mimeType)
}
client := &http.Client{
Timeout: 120 * time.Second,
}
@@ -433,7 +602,7 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo
}
fmt.Printf("Downloading to: %s\n", outputFilename)
if err := t.DownloadFile(downloadURL, outputFilename); err != nil {
if err := t.DownloadFile(downloadURL, outputFilename, quality); err != nil {
cleanupTidalDownloadArtifacts(outputFilename)
return outputFilename, err
}
@@ -493,6 +662,10 @@ func (t *TidalDownloader) Download(spotifyTrackID, outputDir, quality, filenameF
return "", fmt.Errorf("songlink/songstats couldn't find Tidal URL: %w", err)
}
if t.apiURL != "" {
return t.DownloadByURL(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL, allowFallback, useFirstArtistOnly, useSingleGenre, embedGenre)
}
return t.DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL, allowFallback, useFirstArtistOnly, useSingleGenre, embedGenre)
}
@@ -550,10 +723,12 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
var mpd MPD
var segTemplate *SegmentTemplate
var dashMimeType string
if err := xml.Unmarshal(manifestBytes, &mpd); err == nil {
var selectedBandwidth int
var selectedCodecs string
var selectedMimeType string
for _, as := range mpd.Period.AdaptationSets {
@@ -562,6 +737,7 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
if segTemplate == nil {
segTemplate = as.SegmentTemplate
selectedCodecs = as.Codecs
selectedMimeType = as.MimeType
}
}
@@ -576,6 +752,8 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
} else {
selectedCodecs = as.Codecs
}
selectedMimeType = as.MimeType
}
}
}
@@ -583,6 +761,7 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
if selectedBandwidth > 0 {
fmt.Printf("Selected stream: Codec=%s, Bandwidth=%d bps\n", selectedCodecs, selectedBandwidth)
dashMimeType = fmt.Sprintf("%s; codecs=\"%s\"", selectedMimeType, selectedCodecs)
}
}
@@ -608,7 +787,7 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
mediaURL := strings.ReplaceAll(mediaTemplate, "$Number$", fmt.Sprintf("%d", i))
mediaURLs = append(mediaURLs, mediaURL)
}
return "", initURL, mediaURLs, "", nil
return "", initURL, mediaURLs, dashMimeType, nil
}
fmt.Println("Using regex fallback for DASH manifest...")
@@ -655,7 +834,7 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
mediaURLs = append(mediaURLs, mediaURL)
}
return "", initURL, mediaURLs, "", nil
return "", initURL, mediaURLs, dashMimeType, nil
}
func (t *TidalDownloader) downloadWithRotatingAPIs(trackID int64, outputFilename string, quality string, allowFallback bool) (string, error) {
@@ -684,7 +863,7 @@ func (t *TidalDownloader) downloadWithRotatingAPIs(trackID int64, outputFilename
}
func (t *TidalDownloader) tryDownloadAcrossTidalAPIs(trackID int64, outputFilename string, quality string, refreshed bool) (string, error) {
apis, err := GetRotatedTidalAPIList()
apis, err := getConfiguredTidalAPIAttemptList()
if err != nil && len(apis) == 0 {
return "", fmt.Errorf("failed to load tidal api list: %w", err)
}
@@ -706,7 +885,7 @@ func (t *TidalDownloader) tryDownloadAcrossTidalAPIs(trackID int64, outputFilena
continue
}
if err := downloader.DownloadFile(downloadURL, outputFilename); err != nil {
if err := downloader.DownloadFile(downloadURL, outputFilename, quality); err != nil {
lastErr = err
cleanupTidalDownloadArtifacts(outputFilename)
errors = append(errors, fmt.Sprintf("%s: %v", apiURL, err))
-238
View File
@@ -1,238 +0,0 @@
package backend
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
)
const tidalAltDownloadAPIBaseURL = "https://tidal.spotbye.qzz.io/get"
type TidalAltAPIResponse struct {
Title string `json:"title"`
Link string `json:"link"`
}
func buildTidalOutputPath(outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyTrackNumber, spotifyDiscNumber int, isrcOverride string, useFirstArtistOnly bool) (string, bool, error) {
if outputDir != "." {
if err := os.MkdirAll(outputDir, 0755); err != nil {
return "", false, fmt.Errorf("directory error: %w", err)
}
}
artistNameForFile := sanitizeFilename(spotifyArtistName)
albumArtistForFile := sanitizeFilename(spotifyAlbumArtist)
if useFirstArtistOnly {
artistNameForFile = sanitizeFilename(GetFirstArtist(spotifyArtistName))
albumArtistForFile = sanitizeFilename(GetFirstArtist(spotifyAlbumArtist))
}
trackTitleForFile := sanitizeFilename(spotifyTrackName)
albumTitleForFile := sanitizeFilename(spotifyAlbumName)
filename := buildTidalFilename(trackTitleForFile, artistNameForFile, albumTitleForFile, albumArtistForFile, spotifyReleaseDate, spotifyTrackNumber, spotifyDiscNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber, isrcOverride)
outputFilename := filepath.Join(outputDir, filename)
outputFilename, alreadyExists := ResolveOutputPathForDownload(outputFilename, GetRedownloadWithSuffixSetting())
return outputFilename, alreadyExists, nil
}
func finalizeTidalDownload(outputFilename, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL string, useSingleGenre bool, embedGenre bool) {
trackTitle := spotifyTrackName
artistName := spotifyArtistName
albumTitle := spotifyAlbumName
type mbResult struct {
ISRC string
Metadata Metadata
}
metaChan := make(chan mbResult, 1)
if embedGenre && spotifyURL != "" {
go func() {
res := mbResult{}
var isrc string
parts := strings.Split(spotifyURL, "/")
if len(parts) > 0 {
sID := strings.Split(parts[len(parts)-1], "?")[0]
if sID != "" {
client := NewSongLinkClient()
if val, err := client.GetISRC(sID); err == nil {
isrc = val
}
}
}
res.ISRC = isrc
if isrc != "" {
if ShouldSkipMusicBrainzMetadataFetch() {
fmt.Println("Skipping MusicBrainz metadata fetch because status check is offline.")
} else {
fmt.Println("Fetching MusicBrainz metadata...")
if fetchedMeta, err := FetchMusicBrainzMetadata(isrc, trackTitle, artistName, albumTitle, useSingleGenre, embedGenre); err == nil {
res.Metadata = fetchedMeta
fmt.Println("✓ MusicBrainz metadata fetched")
} else {
fmt.Printf("Warning: Failed to fetch MusicBrainz metadata: %v\n", err)
}
}
}
metaChan <- res
}()
} else {
close(metaChan)
}
isrc := strings.TrimSpace(isrcOverride)
var mbMeta Metadata
if spotifyURL != "" {
result := <-metaChan
if isrc == "" {
isrc = result.ISRC
}
mbMeta = result.Metadata
}
upc := ""
if spotifyURL != "" {
if identifiers, err := GetSpotifyTrackIdentifiersDirect(spotifyURL); err == nil || identifiers.ISRC != "" || identifiers.UPC != "" {
if strings.TrimSpace(isrc) == "" && strings.TrimSpace(identifiers.ISRC) != "" {
isrc = strings.TrimSpace(identifiers.ISRC)
}
upc = strings.TrimSpace(identifiers.UPC)
}
}
fmt.Println("Adding metadata...")
coverPath := ""
if spotifyCoverURL != "" {
coverPath = outputFilename + ".cover.jpg"
coverClient := NewCoverClient()
if err := coverClient.DownloadCoverToPath(spotifyCoverURL, coverPath, embedMaxQualityCover); err != nil {
fmt.Printf("Warning: Failed to download Spotify cover: %v\n", err)
coverPath = ""
} else {
defer os.Remove(coverPath)
fmt.Println("Spotify cover downloaded")
}
}
trackNumberToEmbed := spotifyTrackNumber
if trackNumberToEmbed == 0 {
trackNumberToEmbed = 1
}
metadata := Metadata{
Title: trackTitle,
Artist: artistName,
Album: albumTitle,
AlbumArtist: spotifyAlbumArtist,
Date: spotifyReleaseDate,
TrackNumber: trackNumberToEmbed,
TotalTracks: spotifyTotalTracks,
DiscNumber: spotifyDiscNumber,
TotalDiscs: spotifyTotalDiscs,
URL: spotifyURL,
Comment: spotifyURL,
Copyright: spotifyCopyright,
Publisher: spotifyPublisher,
Composer: spotifyComposer,
Separator: metadataSeparator,
Description: "https://github.com/spotbye/SpotiFLAC",
ISRC: isrc,
UPC: upc,
Genre: mbMeta.Genre,
}
if err := EmbedMetadata(outputFilename, metadata, coverPath); err != nil {
fmt.Printf("Tagging failed: %v\n", err)
} else {
fmt.Println("Metadata saved")
}
}
func (t *TidalDownloader) GetAltDownloadURLFromSpotify(spotifyTrackID string) (string, error) {
spotifyTrackID = strings.TrimSpace(spotifyTrackID)
if spotifyTrackID == "" {
return "", fmt.Errorf("spotify track ID is required")
}
apiURL := fmt.Sprintf("%s/%s", tidalAltDownloadAPIBaseURL, spotifyTrackID)
fmt.Printf("Tidal Alt. API URL: %s\n", apiURL)
req, err := NewRequestWithDefaultHeaders(http.MethodGet, apiURL, nil)
if err != nil {
return "", fmt.Errorf("failed to create Tidal Alt. request: %w", err)
}
resp, err := t.client.Do(req)
if err != nil {
return "", fmt.Errorf("failed to get Tidal Alt. download URL: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read Tidal Alt. response: %w", err)
}
if resp.StatusCode != http.StatusOK {
preview := strings.TrimSpace(string(body))
if len(preview) > 200 {
preview = preview[:200] + "..."
}
return "", fmt.Errorf("Tidal Alt. returned status %d: %s", resp.StatusCode, preview)
}
var payload TidalAltAPIResponse
if err := json.Unmarshal(body, &payload); err != nil {
return "", fmt.Errorf("failed to decode Tidal Alt. response: %w", err)
}
downloadURL := strings.TrimSpace(payload.Link)
if downloadURL == "" {
return "", fmt.Errorf("Tidal Alt. response did not include a download link")
}
fmt.Println("✓ Tidal Alt. download URL found")
return downloadURL, nil
}
func (t *TidalDownloader) DownloadAlt(spotifyTrackID, outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL string, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) {
spotifyTrackID = strings.TrimSpace(spotifyTrackID)
if spotifyTrackID == "" {
return "", fmt.Errorf("spotify track ID is required for Tidal Alt.")
}
outputFilename, alreadyExists, err := buildTidalOutputPath(outputDir, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyTrackNumber, spotifyDiscNumber, isrcOverride, useFirstArtistOnly)
if err != nil {
return "", err
}
if alreadyExists {
fmt.Printf("File already exists: %s (%.2f MB)\n", outputFilename, float64(mustFileSize(outputFilename))/(1024*1024))
return "EXISTS:" + outputFilename, nil
}
fmt.Printf("Using Tidal Alt. for Spotify track: %s\n", spotifyTrackID)
downloadURL, err := t.GetAltDownloadURLFromSpotify(spotifyTrackID)
if err != nil {
return outputFilename, err
}
fmt.Printf("Downloading to: %s\n", outputFilename)
if err := t.DownloadFile(downloadURL, outputFilename); err != nil {
cleanupTidalDownloadArtifacts(outputFilename)
return outputFilename, err
}
finalizeTidalDownload(outputFilename, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL, useSingleGenre, embedGenre)
fmt.Println("Done")
fmt.Println("✓ Downloaded successfully from Tidal Alt.")
return outputFilename, nil
}
+1
View File
@@ -20,6 +20,7 @@
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
+1 -1
View File
@@ -1 +1 @@
867c45db7982e126a7249d80210f23be
8864b4f7b7971b624d1ba25030f2db4e
+3
View File
@@ -32,6 +32,9 @@ importers:
'@radix-ui/react-select':
specifier: ^2.2.6
version: 2.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-slider':
specifier: ^1.3.6
version: 1.3.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-slot':
specifier: ^1.2.4
version: 1.2.4(@types/react@19.2.14)(react@19.2.4)
+9 -9
View File
@@ -162,7 +162,7 @@ function App() {
if (savedSettings) {
applyThemeMode(savedSettings.themeMode);
applyTheme(savedSettings.theme);
applyFont(savedSettings.fontFamily);
applyFont(savedSettings.fontFamily, savedSettings.customFonts);
}
}, []);
useEffect(() => {
@@ -170,7 +170,7 @@ function App() {
const settings = await loadSettings();
applyThemeMode(settings.themeMode);
applyTheme(settings.theme);
applyFont(settings.fontFamily);
applyFont(settings.fontFamily, settings.customFonts);
if (!settings.downloadPath) {
const settingsWithDefaults = await getSettingsWithDefaults();
await saveSettings(settingsWithDefaults);
@@ -446,7 +446,7 @@ function App() {
}
if ("album_info" in metadata.metadata) {
const { album_info, track_list } = metadata.metadata;
return (<AlbumInfo albumInfo={album_info} trackList={track_list} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={download.downloadedTracks} failedTracks={download.failedTracks} skippedTracks={download.skippedTracks} downloadingTrack={download.downloadingTrack} isDownloading={download.isDownloading} bulkDownloadType={download.bulkDownloadType} downloadProgress={download.downloadProgress} currentDownloadInfo={download.currentDownloadInfo} currentPage={currentListPage} itemsPerPage={ITEMS_PER_PAGE} downloadedLyrics={lyrics.downloadedLyrics} failedLyrics={lyrics.failedLyrics} skippedLyrics={lyrics.skippedLyrics} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} checkingAvailabilityTrack={availability.checkingTrackId} availabilityMap={availability.availabilityMap} downloadedCovers={cover.downloadedCovers} failedCovers={cover.failedCovers} skippedCovers={cover.skippedCovers} downloadingCoverTrack={cover.downloadingCoverTrack} isBulkDownloadingCovers={cover.isBulkDownloadingCovers} isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics} isMetadataLoading={metadata.loading} onSearchChange={handleSearchChange} onSortChange={setSortBy} onToggleTrack={toggleTrackSelection} onToggleSelectAll={toggleSelectAll} onDownloadTrack={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, album_info.name, position, albumArtist, releaseDate, discNumber, true)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, album_info.name, position, trackId, albumArtist, releaseDate, discNumber, true)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, album_info.name, undefined, true)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, album_info.name, true)} onDownloadAll={() => download.handleDownloadAll(track_list, album_info.name, true)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, album_info.name, true)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onPageChange={setCurrentListPage} onBack={metadata.resetMetadata} onArtistClick={async (artist) => {
return (<AlbumInfo albumInfo={album_info} trackList={track_list} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={download.downloadedTracks} failedTracks={download.failedTracks} skippedTracks={download.skippedTracks} downloadingTrack={download.downloadingTrack} isDownloading={download.isDownloading} bulkDownloadType={download.bulkDownloadType} downloadProgress={download.downloadProgress} downloadRemainingCount={download.downloadRemainingCount} currentDownloadInfo={download.currentDownloadInfo} currentPage={currentListPage} itemsPerPage={ITEMS_PER_PAGE} downloadedLyrics={lyrics.downloadedLyrics} failedLyrics={lyrics.failedLyrics} skippedLyrics={lyrics.skippedLyrics} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} checkingAvailabilityTrack={availability.checkingTrackId} availabilityMap={availability.availabilityMap} downloadedCovers={cover.downloadedCovers} failedCovers={cover.failedCovers} skippedCovers={cover.skippedCovers} downloadingCoverTrack={cover.downloadingCoverTrack} isBulkDownloadingCovers={cover.isBulkDownloadingCovers} isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics} isMetadataLoading={metadata.loading} onSearchChange={handleSearchChange} onSortChange={setSortBy} onToggleTrack={toggleTrackSelection} onToggleSelectAll={toggleSelectAll} onDownloadTrack={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, album_info.name, position, albumArtist, releaseDate, discNumber, true)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, album_info.name, position, trackId, albumArtist, releaseDate, discNumber, true)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, album_info.name, undefined, true)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, album_info.name, true)} onDownloadAll={() => download.handleDownloadAll(track_list, album_info.name, true)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, album_info.name, true)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onPageChange={setCurrentListPage} onBack={metadata.resetMetadata} onArtistClick={async (artist) => {
const pendingArtistUrl = artist.external_urls.replace(/\/$/, "") + "/discography/all";
setSpotifyUrl(pendingArtistUrl);
const artistUrl = await metadata.handleArtistClick(artist);
@@ -464,7 +464,7 @@ function App() {
const { playlist_info, track_list } = metadata.metadata;
const settings = getSettings();
const playlistFolderName = buildPlaylistFolderName(playlist_info.owner.name, playlist_info.owner.display_name, settings.playlistOwnerFolderName);
return (<PlaylistInfo playlistInfo={playlist_info} trackList={track_list} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={download.downloadedTracks} failedTracks={download.failedTracks} skippedTracks={download.skippedTracks} downloadingTrack={download.downloadingTrack} isDownloading={download.isDownloading} bulkDownloadType={download.bulkDownloadType} downloadProgress={download.downloadProgress} currentDownloadInfo={download.currentDownloadInfo} currentPage={currentListPage} itemsPerPage={ITEMS_PER_PAGE} downloadedLyrics={lyrics.downloadedLyrics} failedLyrics={lyrics.failedLyrics} skippedLyrics={lyrics.skippedLyrics} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} checkingAvailabilityTrack={availability.checkingTrackId} availabilityMap={availability.availabilityMap} downloadedCovers={cover.downloadedCovers} failedCovers={cover.failedCovers} skippedCovers={cover.skippedCovers} downloadingCoverTrack={cover.downloadingCoverTrack} isBulkDownloadingCovers={cover.isBulkDownloadingCovers} isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics} isMetadataLoading={metadata.loading} onSearchChange={handleSearchChange} onSortChange={setSortBy} onToggleTrack={toggleTrackSelection} onToggleSelectAll={toggleSelectAll} onDownloadTrack={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, playlistFolderName, position, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, playlistFolderName, position, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, playlistFolderName)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, playlistFolderName)} onDownloadAll={() => download.handleDownloadAll(track_list, playlistFolderName)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, playlistFolderName)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onPageChange={setCurrentListPage} onBack={metadata.resetMetadata} onAlbumClick={metadata.handleAlbumClick} onArtistClick={async (artist) => {
return (<PlaylistInfo playlistInfo={playlist_info} trackList={track_list} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={download.downloadedTracks} failedTracks={download.failedTracks} skippedTracks={download.skippedTracks} downloadingTrack={download.downloadingTrack} isDownloading={download.isDownloading} bulkDownloadType={download.bulkDownloadType} downloadProgress={download.downloadProgress} downloadRemainingCount={download.downloadRemainingCount} currentDownloadInfo={download.currentDownloadInfo} currentPage={currentListPage} itemsPerPage={ITEMS_PER_PAGE} downloadedLyrics={lyrics.downloadedLyrics} failedLyrics={lyrics.failedLyrics} skippedLyrics={lyrics.skippedLyrics} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} checkingAvailabilityTrack={availability.checkingTrackId} availabilityMap={availability.availabilityMap} downloadedCovers={cover.downloadedCovers} failedCovers={cover.failedCovers} skippedCovers={cover.skippedCovers} downloadingCoverTrack={cover.downloadingCoverTrack} isBulkDownloadingCovers={cover.isBulkDownloadingCovers} isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics} isMetadataLoading={metadata.loading} onSearchChange={handleSearchChange} onSortChange={setSortBy} onToggleTrack={toggleTrackSelection} onToggleSelectAll={toggleSelectAll} onDownloadTrack={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, playlistFolderName, position, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, playlistFolderName, position, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, playlistFolderName)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, playlistFolderName)} onDownloadAll={() => download.handleDownloadAll(track_list, playlistFolderName)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, playlistFolderName)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onPageChange={setCurrentListPage} onBack={metadata.resetMetadata} onAlbumClick={metadata.handleAlbumClick} onArtistClick={async (artist) => {
const pendingArtistUrl = artist.external_urls.replace(/\/$/, "") + "/discography/all";
setSpotifyUrl(pendingArtistUrl);
const artistUrl = await metadata.handleArtistClick(artist);
@@ -480,7 +480,7 @@ function App() {
}
if ("artist_info" in metadata.metadata) {
const { artist_info, album_list, track_list } = metadata.metadata;
return (<ArtistInfo artistInfo={artist_info} albumList={album_list} trackList={track_list} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={download.downloadedTracks} failedTracks={download.failedTracks} skippedTracks={download.skippedTracks} downloadingTrack={download.downloadingTrack} isDownloading={download.isDownloading} bulkDownloadType={download.bulkDownloadType} downloadProgress={download.downloadProgress} currentDownloadInfo={download.currentDownloadInfo} currentPage={currentListPage} itemsPerPage={ITEMS_PER_PAGE} downloadedLyrics={lyrics.downloadedLyrics} failedLyrics={lyrics.failedLyrics} skippedLyrics={lyrics.skippedLyrics} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} checkingAvailabilityTrack={availability.checkingTrackId} availabilityMap={availability.availabilityMap} downloadedCovers={cover.downloadedCovers} failedCovers={cover.failedCovers} skippedCovers={cover.skippedCovers} downloadingCoverTrack={cover.downloadingCoverTrack} isBulkDownloadingCovers={cover.isBulkDownloadingCovers} isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics} isMetadataLoading={metadata.loading} onSearchChange={handleSearchChange} onSortChange={setSortBy} onToggleTrack={toggleTrackSelection} onToggleSelectAll={toggleSelectAll} onDownloadTrack={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, artist_info.name, position, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, artist_info.name, position, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, artist_info.name)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, artist_info.name)} onDownloadAll={() => download.handleDownloadAll(track_list, artist_info.name)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, artist_info.name)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onAlbumClick={metadata.handleAlbumClick} onBack={metadata.resetMetadata} onArtistClick={async (artist) => {
return (<ArtistInfo artistInfo={artist_info} albumList={album_list} trackList={track_list} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={download.downloadedTracks} failedTracks={download.failedTracks} skippedTracks={download.skippedTracks} downloadingTrack={download.downloadingTrack} isDownloading={download.isDownloading} bulkDownloadType={download.bulkDownloadType} downloadProgress={download.downloadProgress} downloadRemainingCount={download.downloadRemainingCount} currentDownloadInfo={download.currentDownloadInfo} currentPage={currentListPage} itemsPerPage={ITEMS_PER_PAGE} downloadedLyrics={lyrics.downloadedLyrics} failedLyrics={lyrics.failedLyrics} skippedLyrics={lyrics.skippedLyrics} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} checkingAvailabilityTrack={availability.checkingTrackId} availabilityMap={availability.availabilityMap} downloadedCovers={cover.downloadedCovers} failedCovers={cover.failedCovers} skippedCovers={cover.skippedCovers} downloadingCoverTrack={cover.downloadingCoverTrack} isBulkDownloadingCovers={cover.isBulkDownloadingCovers} isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics} isMetadataLoading={metadata.loading} onSearchChange={handleSearchChange} onSortChange={setSortBy} onToggleTrack={toggleTrackSelection} onToggleSelectAll={toggleSelectAll} onDownloadTrack={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, artist_info.name, position, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, artist_info.name, position, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, artist_info.name)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, artist_info.name)} onDownloadAll={() => download.handleDownloadAll(track_list, artist_info.name)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, artist_info.name)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onAlbumClick={metadata.handleAlbumClick} onBack={metadata.resetMetadata} onArtistClick={async (artist) => {
const pendingArtistUrl = artist.external_urls.replace(/\/$/, "") + "/discography/all";
setSpotifyUrl(pendingArtistUrl);
const artistUrl = await metadata.handleArtistClick(artist);
@@ -512,7 +512,7 @@ function App() {
const savedSettings = getSettings();
applyThemeMode(savedSettings.themeMode);
applyTheme(savedSettings.theme);
applyFont(savedSettings.fontFamily);
applyFont(savedSettings.fontFamily, savedSettings.customFonts);
if (pendingPageChange) {
setCurrentPage(pendingPageChange);
setPendingPageChange(null);
@@ -551,7 +551,7 @@ function App() {
<Dialog open={metadata.showAlbumDialog} onOpenChange={metadata.setShowAlbumDialog}>
<DialogContent className="sm:max-w-[425px] p-6 [&>button]:hidden">
<DialogContent className="sm:max-w-106.25 p-6 [&>button]:hidden">
<div className="absolute right-4 top-4">
<Button variant="ghost" size="icon" className="h-6 w-6 opacity-70 hover:opacity-100" onClick={() => metadata.setShowAlbumDialog(false)}>
<X className="h-4 w-4"/>
@@ -624,7 +624,7 @@ function App() {
<Dialog open={showUnsavedChangesDialog} onOpenChange={setShowUnsavedChangesDialog}>
<DialogContent className="sm:max-w-[425px] [&>button]:hidden">
<DialogContent className="sm:max-w-106.25 [&>button]:hidden">
<DialogHeader>
<DialogTitle>Unsaved Changes</DialogTitle>
<DialogDescription>
@@ -671,7 +671,7 @@ function App() {
</Dialog>
<Dialog open={isFFmpegInstalled === false} onOpenChange={() => { }}>
<DialogContent className="max-w-[450px] [&>button]:hidden p-6 gap-5">
<DialogContent className="max-w-112.5 [&>button]:hidden p-6 gap-5">
<DialogHeader className="space-y-2">
<DialogTitle className="text-lg font-bold tracking-tight">
FFmpeg Required
+2 -2
View File
@@ -249,7 +249,7 @@ export function AboutPage() {
Note
</div>
<p className="text-xs leading-snug text-sky-700 dark:text-sky-300">
This project was created as a thank-you to everyone who has supported SpotiFLAC on Ko-fi.
This project released as a token of appreciation for those who have supported SpotiFLAC on Ko-fi. Its not a paid product, but its shared privately through a supporter-only post.
</p>
</div>
</CardContent>)}
@@ -318,7 +318,7 @@ export function AboutPage() {
<CardTitle className="leading-tight">Browser Extensions & Scripts</CardTitle>
<CardDescription className="flex flex-col gap-2.5 pt-1.5">
{browserExtensionItems.map((item) => (<div key={item.alt} className="flex items-center gap-2.5">
<img src={item.icon} className="h-[22px] w-[22px] rounded-sm shadow-sm" alt={item.alt}/>
<img src={item.icon} className="h-5.5 w-5.5 rounded-sm shadow-sm" alt={item.alt}/>
<span className={`${projectBodyClass} text-muted-foreground`}>
{item.label}
</span>
+3 -2
View File
@@ -35,6 +35,7 @@ interface AlbumInfoProps {
isDownloading: boolean;
bulkDownloadType: "all" | "selected" | null;
downloadProgress: number;
downloadRemainingCount: number;
currentDownloadInfo: {
name: string;
artists: string;
@@ -77,7 +78,7 @@ interface AlbumInfoProps {
onTrackClick?: (track: TrackMetadata) => void;
onBack?: () => void;
}
export function AlbumInfo({ albumInfo, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, isMetadataLoading = false, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onPageChange, onArtistClick, onTrackClick, onBack, }: AlbumInfoProps) {
export function AlbumInfo({ albumInfo, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, downloadRemainingCount, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, isMetadataLoading = false, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onPageChange, onArtistClick, onTrackClick, onBack, }: AlbumInfoProps) {
const settings = getSettings();
const albumArtistNames = splitArtistNames(albumInfo.artists);
const artistSeparator = albumInfo.artists.includes(";") ? "; " : ", ";
@@ -270,7 +271,7 @@ export function AlbumInfo({ albumInfo, trackList, searchQuery, sortBy, selectedT
</TooltipContent>
</Tooltip>)}
</div>
{isDownloading && (<DownloadProgress progress={downloadProgress} currentTrack={currentDownloadInfo} onStop={onStopDownload}/>)}
{isDownloading && (<DownloadProgress progress={downloadProgress} remainingCount={downloadRemainingCount} currentTrack={currentDownloadInfo} onStop={onStopDownload}/>)}
</div>
</div>
</CardContent>
+5 -4
View File
@@ -48,6 +48,7 @@ interface ArtistInfoProps {
isDownloading: boolean;
bulkDownloadType: "all" | "selected" | null;
downloadProgress: number;
downloadRemainingCount: number;
currentDownloadInfo: {
name: string;
artists: string;
@@ -95,7 +96,7 @@ interface ArtistInfoProps {
onTrackClick?: (track: TrackMetadata) => void;
onBack?: () => void;
}
export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, isMetadataLoading = false, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onAlbumClick, onArtistClick, onPageChange, onTrackClick, onBack, }: ArtistInfoProps) {
export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, downloadRemainingCount, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, isMetadataLoading = false, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onAlbumClick, onArtistClick, onPageChange, onTrackClick, onBack, }: ArtistInfoProps) {
const [downloadingHeader, setDownloadingHeader] = useState(false);
const [downloadingAvatar, setDownloadingAvatar] = useState(false);
const [downloadingGalleryIndex, setDownloadingGalleryIndex] = useState<number | null>(null);
@@ -325,7 +326,7 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
{artistInfo.header ? (<>
<div className="relative w-full h-64 bg-cover bg-center">
<div className="absolute inset-0 bg-cover bg-center" style={{ backgroundImage: `url(${artistInfo.header})` }}/>
<div className="absolute inset-0 bg-gradient-to-t from-black via-black/50 to-transparent"/>
<div className="absolute inset-0 bg-linear-to-t from-black via-black/50 to-transparent"/>
{onBack && (<div className="absolute top-4 right-4 z-10">
<Button variant="ghost" size="icon" onClick={onBack} className="text-white hover:bg-white/20 hover:text-white">
<XCircle className="h-5 w-5"/>
@@ -563,7 +564,7 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
Filter Albums
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[500px] h-[80vh] flex flex-col">
<DialogContent className="sm:max-w-125 h-[80vh] flex flex-col">
<DialogHeader>
<DialogTitle>Select Albums</DialogTitle>
</DialogHeader>
@@ -634,7 +635,7 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
</Tooltip>)}
</div>
</div>
{isDownloading && (<DownloadProgress progress={downloadProgress} currentTrack={currentDownloadInfo} onStop={onStopDownload}/>)}
{isDownloading && (<DownloadProgress progress={downloadProgress} remainingCount={downloadRemainingCount} currentTrack={currentDownloadInfo} onStop={onStopDownload}/>)}
<SearchAndSort searchQuery={searchQuery} sortBy={sortBy} onSearchChange={onSearchChange} onSortChange={onSortChange}/>
<TrackList tracks={trackList} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={downloadedTracks} failedTracks={failedTracks} skippedTracks={skippedTracks} downloadingTrack={downloadingTrack} isDownloading={isDownloading} currentPage={currentPage} itemsPerPage={itemsPerPage} showCheckboxes={true} hideAlbumColumn={false} folderName={artistInfo.name} isArtistDiscography={true} downloadedLyrics={downloadedLyrics} failedLyrics={failedLyrics} skippedLyrics={skippedLyrics} downloadingLyricsTrack={downloadingLyricsTrack} checkingAvailabilityTrack={checkingAvailabilityTrack} availabilityMap={availabilityMap} onToggleTrack={onToggleTrack} onToggleSelectAll={onToggleSelectAll} onDownloadTrack={onDownloadTrack} onDownloadLyrics={onDownloadLyrics} onDownloadCover={onDownloadCover} downloadedCovers={downloadedCovers} failedCovers={failedCovers} skippedCovers={skippedCovers} downloadingCoverTrack={downloadingCoverTrack} onCheckAvailability={onCheckAvailability} onPageChange={onPageChange} onAlbumClick={onAlbumClick} onArtistClick={onArtistClick} onTrackClick={onTrackClick}/>
</div>)}
+5 -2
View File
@@ -3,14 +3,17 @@ import { Progress } from "@/components/ui/progress";
import { StopCircle } from "lucide-react";
interface DownloadProgressProps {
progress: number;
remainingCount?: number;
currentTrack: {
name: string;
artists: string;
} | null;
onStop: () => void;
}
export function DownloadProgress({ progress, currentTrack, onStop }: DownloadProgressProps) {
export function DownloadProgress({ progress, remainingCount = 0, currentTrack, onStop }: DownloadProgressProps) {
const clampedProgress = Math.min(100, Math.max(0, progress));
const safeRemainingCount = Math.max(0, remainingCount);
const remainingLabel = `${safeRemainingCount.toLocaleString()} ${safeRemainingCount === 1 ? "track" : "tracks"} left`;
return (<div className="w-full space-y-2 mt-4">
<div className="flex items-center gap-2">
<Progress value={clampedProgress} className="h-2 flex-1"/>
@@ -20,7 +23,7 @@ export function DownloadProgress({ progress, currentTrack, onStop }: DownloadPro
</Button>
</div>
<p className="text-xs text-muted-foreground">
{clampedProgress}% -{" "}
{clampedProgress}% {remainingLabel} -{" "}
{currentTrack
? `${currentTrack.name} - ${currentTrack.artists}`
: "Preparing download..."}
+61 -15
View File
@@ -9,7 +9,8 @@ import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, Pagi
import { GetDownloadHistory, ClearDownloadHistory, GetPreviewURL, GetFetchHistory, DeleteDownloadHistoryItem, DeleteFetchHistoryItem, ClearFetchHistoryByType } from "../../wailsjs/go/main/App";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { openExternal } from "@/lib/utils";
import { SPOTIFY_PREVIEW_VOLUME } from "@/lib/preview";
import { getPreviewVolume } from "@/lib/preview";
import { createPreviewPlayback, type PreviewPlayback } from "@/lib/preview-player";
import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons";
const formatDate = (timestamp: number) => {
const date = new Date(timestamp * 1000);
@@ -21,6 +22,37 @@ const formatDate = (timestamp: number) => {
const seconds = String(date.getSeconds()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
};
const getHistoryFormatLabel = (item: DownloadHistoryItem) => {
const normalizedPath = (item.path || "").trim().toLowerCase();
if (normalizedPath.endsWith(".flac"))
return "FLAC";
if (normalizedPath.endsWith(".mp3"))
return "MP3";
if (normalizedPath.endsWith(".m4a"))
return "M4A";
const normalizedFormat = (item.format || "").trim().toLowerCase();
switch (normalizedFormat) {
case "hi_res":
case "hi_res_lossless":
case "lossless":
case "flac":
case "6":
case "7":
case "27":
return "FLAC";
case "alac":
case "apple":
case "atmos":
case "m4a":
case "m4a-aac":
case "m4a-alac":
return "M4A";
case "mp3":
return "MP3";
default:
return (item.format || "-").toUpperCase();
}
};
interface DownloadHistoryItem {
id: string;
spotify_id: string;
@@ -57,7 +89,7 @@ export function HistoryPage({ onHistorySelect }: HistoryPageProps) {
const [downloadSortBy, setDownloadSortBy] = useState("default");
const [downloadCurrentPage, setDownloadCurrentPage] = useState(1);
const [playingPreviewId, setPlayingPreviewId] = useState<string | null>(null);
const audioRef = useRef<HTMLAudioElement | null>(null);
const playbackRef = useRef<PreviewPlayback | null>(null);
const [fetchHistory, setFetchHistory] = useState<FetchHistoryItem[]>([]);
const [filteredFetchHistory, setFilteredFetchHistory] = useState<FetchHistoryItem[]>([]);
const [activeFetchTab, setActiveFetchTab] = useState("track");
@@ -122,9 +154,8 @@ export function HistoryPage({ onHistorySelect }: HistoryPageProps) {
}, [activeTab]);
useEffect(() => {
return () => {
if (audioRef.current) {
audioRef.current.pause();
}
playbackRef.current?.destroy();
playbackRef.current = null;
};
}, []);
useEffect(() => {
@@ -180,20 +211,35 @@ export function HistoryPage({ onHistorySelect }: HistoryPageProps) {
}, [fetchSearchQuery, activeFetchTab]);
const handlePreview = async (id: string, spotifyId: string) => {
if (playingPreviewId === id) {
audioRef.current?.pause();
playbackRef.current?.destroy();
playbackRef.current = null;
setPlayingPreviewId(null);
return;
}
if (audioRef.current) {
audioRef.current.pause();
if (playbackRef.current) {
playbackRef.current.destroy();
playbackRef.current = null;
}
try {
const url = await GetPreviewURL(spotifyId);
if (url) {
const audio = new Audio(url);
audioRef.current = audio;
audio.volume = SPOTIFY_PREVIEW_VOLUME;
audio.onended = () => setPlayingPreviewId(null);
const playback = await createPreviewPlayback(url, getPreviewVolume());
const audio = playback.audio;
playbackRef.current = playback;
audio.onended = () => {
setPlayingPreviewId(null);
if (playbackRef.current?.audio === audio) {
playbackRef.current.destroy();
playbackRef.current = null;
}
};
audio.onerror = () => {
setPlayingPreviewId(null);
if (playbackRef.current?.audio === audio) {
playbackRef.current.destroy();
playbackRef.current = null;
}
};
audio.play();
setPlayingPreviewId(id);
}
@@ -271,7 +317,7 @@ export function HistoryPage({ onHistorySelect }: HistoryPageProps) {
<Input placeholder="Search downloads..." value={downloadSearchQuery} onChange={(e) => setDownloadSearchQuery(e.target.value)} className="pl-8 h-9"/>
</div>
<Select value={downloadSortBy} onValueChange={setDownloadSortBy}>
<SelectTrigger className="w-[180px] h-9">
<SelectTrigger className="w-45 h-9">
<ArrowUpDown className="mr-2 h-4 w-4"/>
<SelectValue placeholder="Sort by"/>
</SelectTrigger>
@@ -329,10 +375,10 @@ export function HistoryPage({ onHistorySelect }: HistoryPageProps) {
<td className="p-3 align-middle text-sm text-muted-foreground hidden md:table-cell">
<div className="truncate">{item.album}</div>
</td>
<td className="p-3 align-middle text-left hidden lg:table-cell">
<td className="p-3 align-middle text-left hidden lg:table-cell">
<div className="flex flex-col items-start gap-1">
<span className="text-xs font-bold text-foreground">
{['HI_RES_LOSSLESS', 'LOSSLESS', 'flac', '6', '7', '27'].includes(item.format?.toLowerCase() || '') ? 'FLAC' : item.format?.toUpperCase()}
{getHistoryFormatLabel(item)}
</span>
{item.quality && <span className="text-[11px] text-muted-foreground leading-none whitespace-nowrap">{item.quality}</span>}
</div>
+3 -2
View File
@@ -41,6 +41,7 @@ interface PlaylistInfoProps {
isDownloading: boolean;
bulkDownloadType: "all" | "selected" | null;
downloadProgress: number;
downloadRemainingCount: number;
currentDownloadInfo: {
name: string;
artists: string;
@@ -88,7 +89,7 @@ interface PlaylistInfoProps {
onTrackClick: (track: TrackMetadata) => void;
onBack?: () => void;
}
export function PlaylistInfo({ playlistInfo, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, isMetadataLoading = false, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onPageChange, onAlbumClick, onArtistClick, onTrackClick, onBack, }: PlaylistInfoProps) {
export function PlaylistInfo({ playlistInfo, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, downloadRemainingCount, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, isMetadataLoading = false, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onPageChange, onAlbumClick, onArtistClick, onTrackClick, onBack, }: PlaylistInfoProps) {
const settings = getSettings();
const playlistName = playlistInfo.owner.name;
const playlistFolderName = buildPlaylistFolderName(playlistName, playlistInfo.owner.display_name, settings.playlistOwnerFolderName);
@@ -235,7 +236,7 @@ export function PlaylistInfo({ playlistInfo, trackList, searchQuery, sortBy, sel
</TooltipContent>
</Tooltip>)}
</div>
{isDownloading && (<DownloadProgress progress={downloadProgress} currentTrack={currentDownloadInfo} onStop={onStopDownload}/>)}
{isDownloading && (<DownloadProgress progress={downloadProgress} remainingCount={downloadRemainingCount} currentTrack={currentDownloadInfo} onStop={onStopDownload}/>)}
</div>
</div>
</CardContent>
+296 -59
View File
@@ -1,28 +1,37 @@
import { useState, useEffect, useCallback } from "react";
import { flushSync } from "react-dom";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { InputWithContext } from "@/components/ui/input-with-context";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select";
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
import { FolderOpen, Save, RotateCcw, Info, ArrowRight, MonitorCog, FolderCog, Router, FolderLock } from "lucide-react";
import { FolderOpen, Save, RotateCcw, Info, ArrowRight, MonitorCog, FolderCog, Router, FolderLock, Plus, Trash2, ExternalLink } from "lucide-react";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog";
import { Switch } from "@/components/ui/switch";
import { getSettings, getSettingsWithDefaults, saveSettings, resetToDefaultSettings, applyThemeMode, applyFont, FONT_OPTIONS, FOLDER_PRESETS, FILENAME_PRESETS, TEMPLATE_VARIABLES, type Settings as SettingsType, type FontFamily, type FolderPreset, type FilenamePreset, } from "@/lib/settings";
import { getSettings, getSettingsWithDefaults, saveSettings, resetToDefaultSettings, applyThemeMode, applyFont, getFontOptions, parseGoogleFontUrl, loadGoogleFontUrl, loadCustomFonts, saveCustomFonts, FOLDER_PRESETS, FILENAME_PRESETS, TEMPLATE_VARIABLES, type Settings as SettingsType, type FontFamily, type CustomFontFamily, type FolderPreset, type FilenamePreset, type ExistingFileCheckMode, } from "@/lib/settings";
import { themes, applyTheme } from "@/lib/themes";
import { SelectFolder, OpenConfigFolder } from "../../wailsjs/go/main/App";
import { SelectFolder, OpenConfigFolder, CheckCustomTidalAPI } from "../../wailsjs/go/main/App";
import { toastWithSound as toast } from "@/lib/toast-with-sound";
import { openExternal } from "@/lib/utils";
import { ApiStatusTab } from "./ApiStatusTab";
import { AmazonIcon, QobuzIcon, SonglinkIcon, SongstatsIcon, TidalIcon } from "./PlatformIcons";
interface SettingsPageProps {
onUnsavedChangesChange?: (hasUnsavedChanges: boolean) => void;
onResetRequest?: (resetFn: () => void) => void;
}
type CustomTidalApiStatus = "idle" | "checking" | "online" | "offline";
export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: SettingsPageProps) {
const [savedSettings, setSavedSettings] = useState<SettingsType>(getSettings());
const [tempSettings, setTempSettings] = useState<SettingsType>(savedSettings);
const [isDark, setIsDark] = useState(document.documentElement.classList.contains("dark"));
const [showResetConfirm, setShowResetConfirm] = useState(false);
const [showAddFontDialog, setShowAddFontDialog] = useState(false);
const [showCustomTidalApiDialog, setShowCustomTidalApiDialog] = useState(false);
const [addFontUrl, setAddFontUrl] = useState("");
const [customTidalApiStatus, setCustomTidalApiStatus] = useState<CustomTidalApiStatus>("idle");
const parsedAddFont = parseGoogleFontUrl(addFontUrl);
const fontOptions = getFontOptions(tempSettings.customFonts);
const hasUnsavedChanges = JSON.stringify(savedSettings) !== JSON.stringify(tempSettings);
const resetToSaved = useCallback(() => {
const freshSavedSettings = getSettings();
@@ -55,14 +64,20 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
useEffect(() => {
applyThemeMode(tempSettings.themeMode);
applyTheme(tempSettings.theme);
applyFont(tempSettings.fontFamily);
applyFont(tempSettings.fontFamily, tempSettings.customFonts);
setTimeout(() => {
setIsDark(document.documentElement.classList.contains("dark"));
}, 0);
}, [tempSettings.themeMode, tempSettings.theme, tempSettings.fontFamily]);
}, [tempSettings.themeMode, tempSettings.theme, tempSettings.fontFamily, tempSettings.customFonts]);
useEffect(() => {
if (showAddFontDialog && parsedAddFont) {
loadGoogleFontUrl(parsedAddFont.url, "spotiflac-add-font-preview");
}
}, [showAddFontDialog, parsedAddFont]);
useEffect(() => {
const loadDefaults = async () => {
if (!savedSettings.downloadPath) {
const currentSettings = getSettings();
if (!currentSettings.downloadPath) {
const settingsWithDefaults = await getSettingsWithDefaults();
setSavedSettings(settingsWithDefaults);
setTempSettings(settingsWithDefaults);
@@ -71,6 +86,14 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
};
loadDefaults();
}, []);
useEffect(() => {
const syncCustomFonts = async () => {
const customFonts = await loadCustomFonts();
setSavedSettings((prev) => ({ ...prev, customFonts }));
setTempSettings((prev) => ({ ...prev, customFonts }));
};
void syncCustomFonts();
}, []);
const handleSave = async () => {
await saveSettings(tempSettings);
setSavedSettings(tempSettings);
@@ -83,7 +106,7 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
setSavedSettings(defaultSettings);
applyThemeMode(defaultSettings.themeMode);
applyTheme(defaultSettings.theme);
applyFont(defaultSettings.fontFamily);
applyFont(defaultSettings.fontFamily, defaultSettings.customFonts);
setShowResetConfirm(false);
toast.success("Settings reset to default");
};
@@ -99,18 +122,100 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
toast.error(`Error selecting folder: ${error}`);
}
};
const closeAddFontDialog = () => {
setShowAddFontDialog(false);
setAddFontUrl("");
};
const handleAddFont = async () => {
if (!parsedAddFont) {
toast.error("Enter a valid Google Fonts URL");
return;
}
const existingFonts = tempSettings.customFonts || [];
const existingIndex = existingFonts.findIndex((font) => font.value === parsedAddFont.value || font.url === parsedAddFont.url);
const customFonts = existingIndex >= 0
? existingFonts.map((font, index) => index === existingIndex ? parsedAddFont : font)
: [...existingFonts, parsedAddFont];
const savedCustomFonts = await saveCustomFonts(customFonts);
setSavedSettings((prev) => ({ ...prev, customFonts: savedCustomFonts }));
setTempSettings((prev) => ({
...prev,
customFonts: savedCustomFonts,
fontFamily: parsedAddFont.value,
}));
closeAddFontDialog();
toast.success(`${parsedAddFont.label} added`);
};
const handleDeleteCustomFont = async (fontValue: CustomFontFamily) => {
const customFonts = (tempSettings.customFonts || []).filter((font) => font.value !== fontValue);
const savedCustomFonts = await saveCustomFonts(customFonts);
const shouldResetSavedFont = savedSettings.fontFamily === fontValue;
const shouldResetTempFont = tempSettings.fontFamily === fontValue;
const nextSavedSettings: SettingsType = {
...savedSettings,
customFonts: savedCustomFonts,
fontFamily: shouldResetSavedFont ? "google-sans" : savedSettings.fontFamily,
};
setSavedSettings(nextSavedSettings);
setTempSettings((prev) => ({
...prev,
customFonts: savedCustomFonts,
fontFamily: shouldResetTempFont ? "google-sans" : prev.fontFamily,
}));
if (shouldResetSavedFont) {
await saveSettings(nextSavedSettings);
}
toast.success("Font deleted");
};
const handleTidalQualityChange = async (value: "LOSSLESS" | "HI_RES_LOSSLESS") => {
setTempSettings((prev) => ({ ...prev, tidalQuality: value }));
};
const handleTidalVariantChange = (value: "tidal" | "alt") => {
setTempSettings((prev) => ({ ...prev, tidalVariant: value }));
};
const handleQobuzQualityChange = (value: "6" | "7" | "27") => {
setTempSettings((prev) => ({ ...prev, qobuzQuality: value }));
};
const handleAutoQualityChange = async (value: "16" | "24") => {
setTempSettings((prev) => ({ ...prev, autoQuality: value }));
};
const persistCustomTidalApi = useCallback(async (nextValue: string) => {
const normalizedValue = nextValue.trim().replace(/\/+$/g, "");
const persistedSettings = getSettings();
const nextSavedSettings: SettingsType = {
...persistedSettings,
customTidalApi: normalizedValue,
};
await saveSettings(nextSavedSettings);
setSavedSettings((prev) => ({
...prev,
customTidalApi: normalizedValue,
}));
setTempSettings((prev) => ({
...prev,
customTidalApi: normalizedValue,
}));
}, []);
const handleCheckCustomTidalApi = async () => {
const normalizedCustomTidalApi = (tempSettings.customTidalApi || "").trim().replace(/\/+$/g, "");
if (!normalizedCustomTidalApi.startsWith("https://")) {
toast.error("Enter a valid HTTPS HiFi API URL");
return;
}
setCustomTidalApiStatus("checking");
try {
const isOnline = await CheckCustomTidalAPI(normalizedCustomTidalApi);
setCustomTidalApiStatus(isOnline ? "online" : "offline");
if (isOnline) {
toast.success("HiFi API instance is online");
}
else {
toast.error("HiFi API instance is offline");
}
}
catch (error) {
console.error("Failed to check custom Tidal API:", error);
setCustomTidalApiStatus("offline");
toast.error(`Failed to check HiFi API instance: ${error}`);
}
};
const [activeTab, setActiveTab] = useState<"general" | "files" | "api">("general");
return (<div className="space-y-4 h-full flex flex-col">
<div className="flex items-center justify-between shrink-0">
@@ -207,18 +312,39 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
<div className="space-y-2">
<Label htmlFor="font">Font</Label>
<Select value={tempSettings.fontFamily} onValueChange={(value: FontFamily) => setTempSettings((prev) => ({ ...prev, fontFamily: value }))}>
<SelectTrigger id="font">
<SelectValue placeholder="Select a font"/>
</SelectTrigger>
<SelectContent>
{FONT_OPTIONS.map((font) => (<SelectItem key={font.value} value={font.value}>
<span style={{ fontFamily: font.fontFamily }}>
{font.label}
</span>
</SelectItem>))}
</SelectContent>
</Select>
<div className="flex flex-wrap items-center gap-2">
<Select value={tempSettings.fontFamily} onValueChange={(value: FontFamily) => setTempSettings((prev) => ({ ...prev, fontFamily: value }))}>
<SelectTrigger id="font" className="max-w-full min-w-40">
<SelectValue placeholder="Select a font"/>
</SelectTrigger>
<SelectContent>
{fontOptions.map((font) => {
const isCustomFont = font.value.startsWith("custom-");
return (<SelectItem key={font.value} value={font.value} indicatorPosition="inline" trailingAction={isCustomFont ? (<Button type="button" variant="ghost" size="icon" className="h-8 w-8 cursor-pointer text-muted-foreground hover:bg-transparent hover:text-destructive" aria-label={`Delete ${font.label}`} onPointerDown={(event) => {
event.preventDefault();
event.stopPropagation();
}} onPointerUp={(event) => {
event.preventDefault();
event.stopPropagation();
}} onClick={(event) => {
event.preventDefault();
event.stopPropagation();
void handleDeleteCustomFont(font.value as CustomFontFamily);
}}>
<Trash2 className="h-3.5 w-3.5 text-inherit"/>
</Button>) : undefined}>
<span style={{ fontFamily: font.fontFamily }}>
{font.label}
</span>
</SelectItem>);
})}
</SelectContent>
</Select>
<Button type="button" variant="outline" onClick={() => setShowAddFontDialog(true)} className="shrink-0 gap-1.5">
<Plus className="h-4 w-4"/>
Add Font
</Button>
</div>
</div>
<div className="flex items-center gap-3 pt-2">
@@ -240,7 +366,7 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
...prev,
linkResolver: value,
}))}>
<SelectTrigger id="link-resolver" className="h-9 w-fit min-w-[140px]">
<SelectTrigger id="link-resolver" className="h-9 w-fit min-w-35">
<SelectValue placeholder="Select a link resolver"/>
</SelectTrigger>
<SelectContent>
@@ -273,8 +399,8 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
<div className="space-y-2">
<Label htmlFor="downloader">Source</Label>
<div className="flex gap-2 flex-wrap">
<Select value={tempSettings.downloader} onValueChange={(value: any) => setTempSettings((prev) => ({
<div className="flex items-center gap-3 flex-wrap">
<Select value={tempSettings.downloader} onValueChange={(value: SettingsType["downloader"]) => setTempSettings((prev) => ({
...prev,
downloader: value,
}))}>
@@ -306,11 +432,11 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
</Select>
{tempSettings.downloader === "auto" && (<>
<Select value={tempSettings.autoOrder || "tidal-qobuz-amazon"} onValueChange={(value: any) => setTempSettings((prev) => ({
<Select value={tempSettings.autoOrder || "tidal-qobuz-amazon"} onValueChange={(value: string) => setTempSettings((prev) => ({
...prev,
autoOrder: value,
}))}>
<SelectTrigger className="h-9 w-fit min-w-[140px]">
<SelectTrigger className="h-9 w-fit min-w-35">
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -427,9 +553,7 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
</Select>
</>)}
{tempSettings.downloader === "tidal" && (tempSettings.tidalVariant === "alt" ? (<div className="h-9 px-3 flex items-center text-sm font-medium border border-input rounded-md bg-muted/30 text-muted-foreground whitespace-nowrap cursor-default">
16-bit/44.1kHz
</div>) : (<Select value={tempSettings.tidalQuality} onValueChange={handleTidalQualityChange}>
{tempSettings.downloader === "tidal" && (<Select value={tempSettings.tidalQuality} onValueChange={handleTidalQualityChange}>
<SelectTrigger className="h-9 w-fit">
<SelectValue />
</SelectTrigger>
@@ -439,7 +563,7 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
24-bit/48kHz
</SelectItem>
</SelectContent>
</Select>))}
</Select>)}
{tempSettings.downloader === "qobuz" && (<Select value={tempSettings.qobuzQuality} onValueChange={handleQobuzQualityChange}>
<SelectTrigger className="h-9 w-fit">
@@ -457,27 +581,12 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
</div>
{(tempSettings.downloader === "tidal" || tempSettings.downloader === "auto") && (<div className="space-y-2 pt-2">
<Label htmlFor="tidal-variant">Tidal Variant</Label>
<Select value={tempSettings.tidalVariant || "tidal"} onValueChange={handleTidalVariantChange}>
<SelectTrigger id="tidal-variant" className="h-9 w-fit min-w-[160px]">
<SelectValue placeholder="Select Tidal variant"/>
</SelectTrigger>
<SelectContent>
<SelectItem value="tidal">Tidal</SelectItem>
<SelectItem value="alt">Tidal Alt.</SelectItem>
</SelectContent>
</Select>
</div>)}
{((tempSettings.downloader === "tidal" &&
tempSettings.tidalVariant !== "alt" &&
tempSettings.tidalQuality === "HI_RES_LOSSLESS") ||
(tempSettings.downloader === "qobuz" &&
tempSettings.qobuzQuality === "27") ||
(tempSettings.downloader === "auto" &&
tempSettings.autoQuality === "24")) && (<div className="flex items-center gap-3 pt-2">
<div className="flex items-center gap-3">
<Switch id="allow-fallback" checked={tempSettings.allowFallback} onCheckedChange={(checked) => setTempSettings((prev) => ({
...prev,
allowFallback: checked,
@@ -485,22 +594,25 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
<Label htmlFor="allow-fallback" className="text-sm font-normal cursor-pointer">
Allow Quality Fallback (16-bit)
</Label>
</div>)}
{(tempSettings.downloader === "auto" || tempSettings.downloader === "tidal") && (<div className="space-y-2 pt-2">
<Label>Custom Instance</Label>
<div className="flex items-center gap-2">
<Button type="button" variant="outline" onClick={() => setShowCustomTidalApiDialog(true)} className="gap-2">
<TidalIcon />
Configure
</Button>
{tempSettings.customTidalApi && (<span className="max-w-[260px] truncate text-xs text-muted-foreground" title={tempSettings.customTidalApi}>
{tempSettings.customTidalApi}
</span>)}
</div>
</div>)}
</div>
<div className="border-t pt-6"/>
<div className="border-t pt-2"/>
<div className="space-y-4">
<div className="flex items-center gap-3">
<Switch id="embed-lyrics" checked={tempSettings.embedLyrics} onCheckedChange={(checked) => setTempSettings((prev) => ({
...prev,
embedLyrics: checked,
}))}/>
<Label htmlFor="embed-lyrics" className="cursor-pointer text-sm font-normal">
Embed Lyrics
</Label>
</div>
<div className="flex items-center gap-3">
<Switch id="embed-max-quality-cover" checked={tempSettings.embedMaxQualityCover} onCheckedChange={(checked) => setTempSettings((prev) => ({
...prev,
@@ -528,6 +640,15 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
Use Single Genre
</Label>
</div>)}
<div className="flex items-center gap-3">
<Switch id="embed-lyrics" checked={tempSettings.embedLyrics} onCheckedChange={(checked) => setTempSettings((prev) => ({
...prev,
embedLyrics: checked,
}))}/>
<Label htmlFor="embed-lyrics" className="cursor-pointer text-sm font-normal">
Embed Lyrics
</Label>
</div>
</div>
</div>
</div>)}
@@ -645,7 +766,24 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
</div>
<div className="space-y-2">
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="existing-file-check-mode">Existing File Check</Label>
<Select value={tempSettings.existingFileCheckMode} onValueChange={(value: ExistingFileCheckMode) => setTempSettings((prev) => ({
...prev,
existingFileCheckMode: value,
}))}>
<SelectTrigger id="existing-file-check-mode">
<SelectValue placeholder="Select existing file check mode"/>
</SelectTrigger>
<SelectContent>
<SelectItem value="filename">Filename</SelectItem>
<SelectItem value="isrc">ISRC</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<div className="flex items-center gap-2">
<Label className="text-sm">Filename Format</Label>
<Tooltip>
@@ -719,20 +857,119 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
.flac
</span>
</p>)}
</div>
</div>
</div>)}
{activeTab === "api" && (<ApiStatusTab />)}
</div>
<Dialog open={showAddFontDialog} onOpenChange={(open) => open ? setShowAddFontDialog(true) : closeAddFontDialog()}>
<DialogContent className="sm:max-w-115 [&>button]:hidden">
<DialogHeader>
<div className="flex items-center justify-between gap-3">
<DialogTitle>Add Font</DialogTitle>
<button type="button" onClick={() => openExternal("https://fonts.google.com")} className="inline-flex cursor-pointer items-center gap-1 text-xs text-muted-foreground hover:text-foreground hover:underline">
Open Google Fonts
<ExternalLink className="h-3 w-3"/>
</button>
</div>
<DialogDescription />
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="google-font-url">Google Font URL</Label>
<Input id="google-font-url" value={addFontUrl} onChange={(event) => setAddFontUrl(event.target.value)} onKeyDown={(event) => {
if (event.key === "Enter" && parsedAddFont) {
void handleAddFont();
}
}} placeholder="https://fonts.google.com/specimen/Ubuntu" autoFocus/>
{addFontUrl.trim() && !parsedAddFont && (<p className="text-xs text-destructive">
Enter a valid Google Fonts URL.
</p>)}
</div>
<div className="rounded-md border bg-muted/20 p-4">
<p className="mb-2 text-xs font-medium text-muted-foreground">
Preview
</p>
<p className="text-2xl font-semibold leading-tight" style={{ fontFamily: parsedAddFont?.fontFamily }}>
Aa The quick brown fox
</p>
<p className="mt-2 text-sm text-muted-foreground" style={{ fontFamily: parsedAddFont?.fontFamily }}>
Kendrick Lamar - All The Stars
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={closeAddFontDialog}>
Cancel
</Button>
<Button onClick={() => void handleAddFont()} disabled={!parsedAddFont}>
Add
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={showCustomTidalApiDialog} onOpenChange={setShowCustomTidalApiDialog}>
<DialogContent className="sm:max-w-md [&>button]:hidden">
<DialogHeader>
<div className="flex items-center justify-between gap-3">
<DialogTitle>Custom Instance</DialogTitle>
<button type="button" onClick={() => openExternal("https://github.com/binimum/hifi-api")} className="inline-flex cursor-pointer items-center gap-1 text-xs text-muted-foreground hover:text-foreground hover:underline">
How to create your own instance
<ExternalLink className="h-3 w-3"/>
</button>
</div>
<DialogDescription />
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="custom-tidal-api">Instance URL</Label>
<div className="flex gap-2">
<Input id="custom-tidal-api" type="url" value={tempSettings.customTidalApi || ""} onChange={(e) => {
const nextValue = e.target.value.replace(/\/+$/g, "");
setCustomTidalApiStatus("idle");
void persistCustomTidalApi(nextValue);
}} placeholder="https://your-hifi-api.example"/>
<Button type="button" variant="outline" onClick={() => void handleCheckCustomTidalApi()} disabled={!((tempSettings.customTidalApi || "").trim().startsWith("https://")) || customTidalApiStatus === "checking"}>
{customTidalApiStatus === "checking" ? "Checking..." : "Check"}
</Button>
{tempSettings.customTidalApi && (<Button type="button" variant="outline" size="icon" onClick={() => {
setCustomTidalApiStatus("idle");
void persistCustomTidalApi("");
}}>
<Trash2 className="h-4 w-4 text-destructive"/>
</Button>)}
</div>
</div>
{customTidalApiStatus !== "idle" && (<p className={`text-xs ${customTidalApiStatus === "online"
? "text-green-600 dark:text-green-400"
: customTidalApiStatus === "offline"
? "text-destructive"
: "text-muted-foreground"}`}>
{customTidalApiStatus === "online"
? "Custom HiFi API instance is online."
: customTidalApiStatus === "offline"
? "Custom HiFi API instance is offline or returned preview-only data."
: "Checking custom HiFi API instance..."}
</p>)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowCustomTidalApiDialog(false)}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={showResetConfirm} onOpenChange={setShowResetConfirm}>
<DialogContent className="max-w-md [&>button]:hidden">
<DialogHeader>
<DialogTitle>Reset to Default?</DialogTitle>
<DialogDescription>
This will reset all settings to their default values. Your custom
configurations will be lost.
font list will be kept.
</DialogDescription>
</DialogHeader>
<DialogFooter>
+46 -2
View File
@@ -1,6 +1,9 @@
import { X, Minus, Maximize, SlidersHorizontal, Globe, Eye, EyeOff } from "lucide-react";
import { WindowMinimise, WindowToggleMaximise, Quit } from "../../wailsjs/runtime/runtime";
import { Menubar, MenubarContent, MenubarMenu, MenubarItem, MenubarTrigger, MenubarLabel, MenubarSeparator } from "@/components/ui/menubar";
import { Slider } from "@/components/ui/slider";
import { getSettings, updateSettings } from "@/lib/settings";
import { PREVIEW_VOLUME_CHANGED_EVENT } from "@/lib/preview";
import { fetchCurrentIPInfo } from "@/lib/api";
import type { CurrentIPInfo } from "@/types/api";
import { openExternal } from "@/lib/utils";
@@ -24,7 +27,12 @@ const SPOTIFY_BLOCKED_COUNTRY_CODES = new Set([
"TM",
"YE",
]);
interface SettingsUpdatedDetail {
previewVolume?: number;
}
export function TitleBar() {
const initialSettings = getSettings();
const [previewVolume, setPreviewVolume] = useState(initialSettings.previewVolume ?? 100);
const [currentIPInfo, setCurrentIPInfo] = useState<CurrentIPInfo | null>(null);
const [isLoadingCurrentIPInfo, setIsLoadingCurrentIPInfo] = useState(false);
const [currentIPInfoError, setCurrentIPInfoError] = useState("");
@@ -33,6 +41,16 @@ export function TitleBar() {
useEffect(() => {
currentIPInfoRef.current = currentIPInfo;
}, [currentIPInfo]);
useEffect(() => {
const handleSettingsUpdate = (event: Event) => {
const updatedSettings = (event as CustomEvent<SettingsUpdatedDetail>).detail;
if (updatedSettings && typeof updatedSettings.previewVolume === "number") {
setPreviewVolume(updatedSettings.previewVolume);
}
};
window.addEventListener("settingsUpdated", handleSettingsUpdate);
return () => window.removeEventListener("settingsUpdated", handleSettingsUpdate);
}, []);
const loadCurrentIPInfo = async (options?: {
silent?: boolean;
}) => {
@@ -88,6 +106,22 @@ export function TitleBar() {
const handleClose = () => {
Quit();
};
const handlePreviewVolumeChange = (value: number[]) => {
const nextValue = value[0];
if (typeof nextValue !== "number" || Number.isNaN(nextValue)) {
return;
}
setPreviewVolume(nextValue);
window.dispatchEvent(new CustomEvent(PREVIEW_VOLUME_CHANGED_EVENT, { detail: nextValue }));
};
const handlePreviewVolumeCommit = (value: number[]) => {
const nextValue = value[0];
if (typeof nextValue !== "number" || Number.isNaN(nextValue)) {
return;
}
setPreviewVolume(nextValue);
void updateSettings({ previewVolume: nextValue });
};
const detectedCountryCode = currentIPInfo?.country_code?.toUpperCase() || "";
const detectedFlagPath = detectedCountryCode ? `/assets/flags/${detectedCountryCode.toLowerCase()}.svg` : "";
const isSpotifyBlockedCountry = detectedCountryCode !== "" && SPOTIFY_BLOCKED_COUNTRY_CODES.has(detectedCountryCode);
@@ -102,7 +136,17 @@ export function TitleBar() {
<MenubarTrigger className="cursor-pointer w-8 h-7 p-0 flex items-center justify-center hover:bg-muted transition-colors rounded data-[state=open]:bg-muted">
<SlidersHorizontal className="w-3.5 h-3.5"/>
</MenubarTrigger>
<MenubarContent align="end" className="min-w-[280px]">
<MenubarContent align="end" className="min-w-70">
<div className="px-2 py-1.5 space-y-2">
<div className="flex items-center justify-between gap-3">
<MenubarLabel className="p-0">Preview Volume</MenubarLabel>
<span className="text-xs font-medium text-muted-foreground tabular-nums">
{previewVolume}%
</span>
</div>
<Slider value={[previewVolume]} min={0} max={100} step={5} onValueChange={handlePreviewVolumeChange} onValueCommit={handlePreviewVolumeCommit} aria-label="Preview volume"/>
</div>
<MenubarSeparator />
<div className="flex items-center gap-1.5 px-2 py-1.5">
<MenubarLabel className="p-0">Network</MenubarLabel>
{isSpotifyBlockedCountry && (<span className="text-xs font-medium text-destructive">
@@ -112,7 +156,7 @@ export function TitleBar() {
<div className="px-2 py-1.5 space-y-1">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 min-w-0">
{detectedFlagPath ? (<img src={detectedFlagPath} alt={detectedCountryCode} className="h-3.5 w-[18px] rounded-[2px] border object-cover bg-muted"/>) : (<Globe className="w-4 h-4 opacity-70"/>)}
{detectedFlagPath ? (<img src={detectedFlagPath} alt={detectedCountryCode} className="h-3.5 w-4.5 rounded-[2px] border object-cover bg-muted"/>) : (<Globe className="w-4 h-4 opacity-70"/>)}
<span className="font-mono text-xs truncate">
{isLoadingCurrentIPInfo
? "Detecting..."
+17 -7
View File
@@ -37,14 +37,24 @@ function SelectContent({ className, children, position = "popper", align = "cent
function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (<SelectPrimitive.Label data-slot="select-label" className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)} {...props}/>);
}
function SelectItem({ className, children, ...props }: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (<SelectPrimitive.Item data-slot="select-item" className={cn("focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2", className)} {...props}>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4"/>
</SelectPrimitive.ItemIndicator>
function SelectItem({ className, children, indicatorPosition = "right", trailingAction, ...props }: React.ComponentProps<typeof SelectPrimitive.Item> & {
indicatorPosition?: "right" | "inline";
trailingAction?: React.ReactNode;
}) {
return (<SelectPrimitive.Item data-slot="select-item" className={cn("focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pl-2 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", indicatorPosition === "right" ? "pr-8" : "pr-2", trailingAction ? "pr-10" : undefined, className)} {...props}>
<span className="flex min-w-0 items-center gap-2">
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
{indicatorPosition === "inline" && (<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4"/>
</SelectPrimitive.ItemIndicator>)}
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
{trailingAction ? (<span className="absolute right-2 flex items-center justify-center">
{trailingAction}
</span>) : indicatorPosition === "right" ? (<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4"/>
</SelectPrimitive.ItemIndicator>
</span>) : null}
</SelectPrimitive.Item>);
}
function SelectSeparator({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Separator>) {
+17
View File
@@ -0,0 +1,17 @@
import * as React from "react";
import * as SliderPrimitive from "@radix-ui/react-slider";
import { cn } from "@/lib/utils";
function Slider({ className, defaultValue, value, min = 0, max = 100, ...props }: React.ComponentProps<typeof SliderPrimitive.Root>) {
const values = Array.isArray(value)
? value
: Array.isArray(defaultValue)
? defaultValue
: [min];
return (<SliderPrimitive.Root data-slot="slider" defaultValue={defaultValue} value={value} min={min} max={max} className={cn("relative flex w-full touch-none select-none items-center data-[disabled]:opacity-50", className)} {...props}>
<SliderPrimitive.Track data-slot="slider-track" className="relative h-2 w-full grow overflow-hidden rounded-full bg-muted">
<SliderPrimitive.Range data-slot="slider-range" className="absolute h-full rounded-full bg-primary"/>
</SliderPrimitive.Track>
{values.map((_, index) => (<SliderPrimitive.Thumb key={index} data-slot="slider-thumb" className="block size-4 shrink-0 rounded-full border-2 border-primary bg-background shadow-sm transition-[color,box-shadow] hover:shadow-md focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-ring/40 disabled:pointer-events-none disabled:opacity-50"/>))}
</SliderPrimitive.Root>);
}
export { Slider };
+48 -42
View File
@@ -36,13 +36,17 @@ const GetTrackISRC = (spotifyId: string): Promise<string> => (window as any)["go
async function resolveTemplateISRC(settings: {
folderTemplate?: string;
filenameTemplate?: string;
existingFileCheckMode?: string;
}, spotifyId?: string): Promise<string> {
if (!spotifyId) {
return "";
}
const folderTemplate = settings.folderTemplate || "";
const filenameTemplate = settings.filenameTemplate || "";
if (!folderTemplate.includes("{isrc}") && !filenameTemplate.includes("{isrc}")) {
const shouldResolveISRC = settings.existingFileCheckMode === "isrc" ||
folderTemplate.includes("{isrc}") ||
filenameTemplate.includes("{isrc}");
if (!shouldResolveISRC) {
return "";
}
try {
@@ -52,26 +56,18 @@ async function resolveTemplateISRC(settings: {
return "";
}
}
function getTidalVariant(settings: any): "tidal" | "alt" {
return settings?.tidalVariant === "alt" ? "alt" : "tidal";
}
function isTidalAltVariant(settings: any): boolean {
return getTidalVariant(settings) === "alt";
}
function getTidalAudioFormat(settings: any, mode: "single" | "auto"): "LOSSLESS" | "HI_RES_LOSSLESS" {
if (isTidalAltVariant(settings)) {
return "LOSSLESS";
}
if (mode === "auto") {
return (settings.autoQuality || "24") === "24" ? "HI_RES_LOSSLESS" : "LOSSLESS";
}
return settings.tidalQuality || "LOSSLESS";
}
function shouldFetchStreamingURLs(order: string[], settings: any): boolean {
return order.includes("amazon") || (order.includes("tidal") && !isTidalAltVariant(settings));
function shouldFetchStreamingURLs(order: string[]): boolean {
return order.includes("amazon") || order.includes("tidal");
}
export function useDownload(region: string) {
const [downloadProgress, setDownloadProgress] = useState<number>(0);
const [downloadRemainingCount, setDownloadRemainingCount] = useState<number>(0);
const [isDownloading, setIsDownloading] = useState(false);
const [downloadingTrack, setDownloadingTrack] = useState<string | null>(null);
const [bulkDownloadType, setBulkDownloadType] = useState<"all" | "selected" | null>(null);
@@ -83,10 +79,19 @@ export function useDownload(region: string) {
artists: string;
} | null>(null);
const shouldStopDownloadRef = useRef(false);
const updateBatchProgress = (completedCount: number, totalCount: number) => {
const safeTotalCount = Math.max(0, totalCount);
const safeCompletedCount = Math.min(Math.max(0, completedCount), safeTotalCount);
setDownloadProgress(safeTotalCount > 0 ? Math.min(100, Math.round((safeCompletedCount / safeTotalCount) * 100)) : 0);
setDownloadRemainingCount(Math.max(0, safeTotalCount - safeCompletedCount));
};
const downloadWithAutoFallback = async (id: string, settings: any, trackName?: string, artistName?: string, albumName?: string, playlistName?: string, position?: number, spotifyId?: string, durationMs?: number, releaseYear?: string, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => {
const service = settings.downloader;
const query = trackName && artistName ? `${trackName} ${artistName} ` : undefined;
const os = settings.operatingSystem;
const customTidalApi = typeof settings.customTidalApi === "string" && settings.customTidalApi.trim().startsWith("https://")
? settings.customTidalApi.trim().replace(/\/+$/g, "")
: undefined;
let outputDir = settings.downloadPath;
let useAlbumTrackNumber = false;
const placeholder = "__SLASH_PLACEHOLDER__";
@@ -189,10 +194,8 @@ export function useDownload(region: string) {
}
if (service === "auto") {
const order = (settings.autoOrder || "tidal-amazon-qobuz").split("-");
const tidalVariant = getTidalVariant(settings);
const tidalLabel = tidalVariant === "alt" ? "Tidal Alt." : "Tidal";
let streamingURLs: any = null;
if (spotifyId && shouldFetchStreamingURLs(order, settings)) {
if (spotifyId && shouldFetchStreamingURLs(order)) {
try {
const { GetStreamingURLs } = await import("../../wailsjs/go/main/App");
const urlsJson = await GetStreamingURLs(spotifyId, region);
@@ -209,9 +212,9 @@ export function useDownload(region: string) {
const is24Bit = (settings.autoQuality || "24") === "24";
const qobuzQuality = is24Bit ? "27" : "6";
for (const s of order) {
if (s === "tidal" && ((tidalVariant === "alt" && spotifyId) || streamingURLs?.tidal_url)) {
if (s === "tidal" && streamingURLs?.tidal_url) {
try {
logger.debug(`trying ${tidalLabel} for: ${trackName} - ${artistName}`);
logger.debug(`trying Tidal for: ${trackName} - ${artistName}`);
const response = await downloadTrack({
service: "tidal",
query,
@@ -229,11 +232,11 @@ export function useDownload(region: string) {
spotify_id: spotifyId,
embed_lyrics: settings.embedLyrics,
embed_max_quality_cover: settings.embedMaxQualityCover,
service_url: tidalVariant === "alt" ? undefined : streamingURLs?.tidal_url,
tidal_variant: tidalVariant,
service_url: streamingURLs?.tidal_url,
duration: durationSeconds,
item_id: itemID,
audio_format: tidalQuality,
tidal_api_url: customTidalApi,
spotify_track_number: spotifyTrackNumber,
spotify_disc_number: spotifyDiscNumber,
spotify_total_tracks: spotifyTotalTracks,
@@ -246,17 +249,17 @@ export function useDownload(region: string) {
embed_genre: settings.embedGenre,
});
if (response.success) {
logger.success(`${tidalLabel}: ${trackName} - ${artistName}`);
logger.success(`Tidal: ${trackName} - ${artistName}`);
return response;
}
const errMsg = response.error || response.message || "Failed";
fallbackErrors.push(`[${tidalLabel}] ${errMsg}`);
fallbackErrors.push(`[Tidal] ${errMsg}`);
lastResponse = response;
logger.warning(`${tidalLabel} failed, trying next...`);
logger.warning(`Tidal failed, trying next...`);
}
catch (err) {
logger.error(`${tidalLabel} error: ${err}`);
fallbackErrors.push(`[${tidalLabel}] ${String(err)}`);
logger.error(`Tidal error: ${err}`);
fallbackErrors.push(`[Tidal] ${String(err)}`);
lastResponse = { success: false, error: String(err) };
}
}
@@ -394,7 +397,7 @@ export function useDownload(region: string) {
duration: durationSecondsForFallback,
item_id: itemID,
audio_format: audioFormat,
tidal_variant: service === "tidal" ? getTidalVariant(settings) : undefined,
tidal_api_url: service === "tidal" ? customTidalApi : undefined,
spotify_track_number: spotifyTrackNumber,
spotify_disc_number: spotifyDiscNumber,
spotify_total_tracks: spotifyTotalTracks,
@@ -475,10 +478,8 @@ export function useDownload(region: string) {
}
if (service === "auto") {
const order = (settings.autoOrder || "tidal-amazon-qobuz").split("-");
const tidalVariant = getTidalVariant(settings);
const tidalLabel = tidalVariant === "alt" ? "Tidal Alt." : "Tidal";
let streamingURLs: any = null;
if (spotifyId && shouldFetchStreamingURLs(order, settings)) {
if (spotifyId && shouldFetchStreamingURLs(order)) {
try {
const { GetStreamingURLs } = await import("../../wailsjs/go/main/App");
const urlsJson = await GetStreamingURLs(spotifyId, region);
@@ -495,9 +496,9 @@ export function useDownload(region: string) {
const is24Bit = (settings.autoQuality || "24") === "24";
const qobuzQuality = is24Bit ? "27" : "6";
for (const s of order) {
if (s === "tidal" && ((tidalVariant === "alt" && spotifyId) || streamingURLs?.tidal_url)) {
if (s === "tidal" && streamingURLs?.tidal_url) {
try {
logger.debug(`trying ${tidalLabel} for: ${trackName} - ${artistName}`);
logger.debug(`trying Tidal for: ${trackName} - ${artistName}`);
const response = await downloadTrack({
service: "tidal",
query,
@@ -515,8 +516,7 @@ export function useDownload(region: string) {
spotify_id: spotifyId,
embed_lyrics: settings.embedLyrics,
embed_max_quality_cover: settings.embedMaxQualityCover,
service_url: tidalVariant === "alt" ? undefined : streamingURLs?.tidal_url,
tidal_variant: tidalVariant,
service_url: streamingURLs?.tidal_url,
duration: durationSeconds,
item_id: itemID,
audio_format: tidalQuality,
@@ -532,17 +532,17 @@ export function useDownload(region: string) {
embed_genre: settings.embedGenre,
});
if (response.success) {
logger.success(`${tidalLabel}: ${trackName} - ${artistName}`);
logger.success(`Tidal: ${trackName} - ${artistName}`);
return response;
}
const errMsg = response.error || response.message || "Failed";
fallbackErrors.push(`[${tidalLabel}] ${errMsg}`);
fallbackErrors.push(`[Tidal] ${errMsg}`);
lastResponse = response;
logger.warning(`${tidalLabel} failed, trying next...`);
logger.warning(`Tidal failed, trying next...`);
}
catch (err) {
logger.error(`${tidalLabel} error: ${err}`);
fallbackErrors.push(`[${tidalLabel}] ${String(err)}`);
logger.error(`Tidal error: ${err}`);
fallbackErrors.push(`[Tidal] ${String(err)}`);
lastResponse = { success: false, error: String(err) };
}
}
@@ -679,7 +679,6 @@ export function useDownload(region: string) {
duration: durationSecondsForFallback,
item_id: itemID,
audio_format: audioFormat,
tidal_variant: service === "tidal" ? getTidalVariant(settings) : undefined,
spotify_track_number: spotifyTrackNumber,
spotify_disc_number: spotifyDiscNumber,
spotify_total_tracks: spotifyTotalTracks,
@@ -747,6 +746,8 @@ export function useDownload(region: string) {
setIsDownloading(true);
setBulkDownloadType("selected");
setDownloadProgress(0);
setDownloadRemainingCount(selectedTracks.length);
setCurrentDownloadInfo(null);
let outputDir = settings.downloadPath;
const os = settings.operatingSystem;
const useAlbumTag = settings.folderTemplate?.includes("{album}");
@@ -815,7 +816,7 @@ export function useDownload(region: string) {
let errorCount = 0;
let skippedCount = existingSpotifyIDs.size;
const total = selectedTracks.length;
setDownloadProgress(Math.round((skippedCount / total) * 100));
updateBatchProgress(skippedCount, total);
for (let i = 0; i < tracksToDownload.length; i++) {
if (shouldStopDownloadRef.current) {
toast.info(`Download stopped. ${successCount} tracks downloaded, ${tracksToDownload.length - i} remaining.`);
@@ -868,12 +869,13 @@ export function useDownload(region: string) {
}
}
const completedCount = skippedCount + successCount + errorCount;
setDownloadProgress(Math.min(100, Math.round((completedCount / total) * 100)));
updateBatchProgress(completedCount, total);
}
setDownloadingTrack(null);
setCurrentDownloadInfo(null);
setIsDownloading(false);
setBulkDownloadType(null);
updateBatchProgress(0, 0);
shouldStopDownloadRef.current = false;
const { CancelAllQueuedItems } = await import("../../wailsjs/go/main/App");
await CancelAllQueuedItems();
@@ -922,6 +924,8 @@ export function useDownload(region: string) {
setIsDownloading(true);
setBulkDownloadType("all");
setDownloadProgress(0);
setDownloadRemainingCount(tracksWithId.length);
setCurrentDownloadInfo(null);
let outputDir = settings.downloadPath;
const os = settings.operatingSystem;
const useAlbumTag = settings.folderTemplate?.includes("{album}");
@@ -985,7 +989,7 @@ export function useDownload(region: string) {
let errorCount = 0;
let skippedCount = existingSpotifyIDs.size;
const total = tracksWithId.length;
setDownloadProgress(Math.round((skippedCount / total) * 100));
updateBatchProgress(skippedCount, total);
for (let i = 0; i < tracksToDownload.length; i++) {
if (shouldStopDownloadRef.current) {
toast.info(`Download stopped. ${successCount} tracks downloaded, ${tracksToDownload.length - i} remaining.`);
@@ -1035,12 +1039,13 @@ export function useDownload(region: string) {
await MarkDownloadItemFailed(itemID, err instanceof Error ? err.message : String(err));
}
const completedCount = skippedCount + successCount + errorCount;
setDownloadProgress(Math.min(100, Math.round((completedCount / total) * 100)));
updateBatchProgress(completedCount, total);
}
setDownloadingTrack(null);
setCurrentDownloadInfo(null);
setIsDownloading(false);
setBulkDownloadType(null);
updateBatchProgress(0, 0);
shouldStopDownloadRef.current = false;
const { CancelAllQueuedItems: CancelQueued } = await import("../../wailsjs/go/main/App");
await CancelQueued();
@@ -1087,6 +1092,7 @@ export function useDownload(region: string) {
};
return {
downloadProgress,
downloadRemainingCount,
isDownloading,
downloadingTrack,
bulkDownloadType,
+5 -1
View File
@@ -9,13 +9,17 @@ const GetTrackISRC = (spotifyId: string): Promise<string> => (window as any)["go
async function resolveTemplateISRC(settings: {
folderTemplate?: string;
filenameTemplate?: string;
existingFileCheckMode?: string;
}, spotifyId?: string): Promise<string> {
if (!spotifyId) {
return "";
}
const folderTemplate = settings.folderTemplate || "";
const filenameTemplate = settings.filenameTemplate || "";
if (!folderTemplate.includes("{isrc}") && !filenameTemplate.includes("{isrc}")) {
const shouldResolveISRC = settings.existingFileCheckMode === "isrc" ||
folderTemplate.includes("{isrc}") ||
filenameTemplate.includes("{isrc}");
if (!shouldResolveISRC) {
return "";
}
try {
+32 -27
View File
@@ -1,32 +1,34 @@
import { useState, useEffect } from "react";
import { useEffect, useRef, useState } from "react";
import { GetPreviewURL } from "@/../wailsjs/go/main/App";
import { SPOTIFY_PREVIEW_VOLUME } from "@/lib/preview";
import { getPreviewVolume } from "@/lib/preview";
import { createPreviewPlayback, type PreviewPlayback } from "@/lib/preview-player";
import { toast } from "sonner";
export function usePreview() {
const [loadingPreview, setLoadingPreview] = useState<string | null>(null);
const [currentAudio, setCurrentAudio] = useState<HTMLAudioElement | null>(null);
const [playingTrack, setPlayingTrack] = useState<string | null>(null);
const currentPlaybackRef = useRef<PreviewPlayback | null>(null);
const stopCurrentAudio = () => {
if (!currentPlaybackRef.current) {
return;
}
currentPlaybackRef.current.destroy();
currentPlaybackRef.current = null;
};
useEffect(() => {
return () => {
if (currentAudio) {
currentAudio.pause();
currentAudio.currentTime = 0;
}
stopCurrentAudio();
};
}, [currentAudio]);
}, []);
const playPreview = async (trackId: string, trackName: string) => {
try {
const currentAudio = currentPlaybackRef.current?.audio;
if (playingTrack === trackId && currentAudio) {
currentAudio.pause();
currentAudio.currentTime = 0;
stopCurrentAudio();
setPlayingTrack(null);
setCurrentAudio(null);
return;
}
if (currentAudio) {
currentAudio.pause();
currentAudio.currentTime = 0;
setCurrentAudio(null);
stopCurrentAudio();
setPlayingTrack(null);
}
setLoadingPreview(trackId);
@@ -38,15 +40,18 @@ export function usePreview() {
setLoadingPreview(null);
return;
}
const audio = new Audio(previewURL);
audio.volume = SPOTIFY_PREVIEW_VOLUME;
const playback = await createPreviewPlayback(previewURL, getPreviewVolume());
const audio = playback.audio;
audio.addEventListener("loadeddata", () => {
setLoadingPreview(null);
setPlayingTrack(trackId);
});
audio.addEventListener("ended", () => {
setPlayingTrack(null);
setCurrentAudio(null);
if (currentPlaybackRef.current?.audio === audio) {
currentPlaybackRef.current.destroy();
currentPlaybackRef.current = null;
}
});
audio.addEventListener("error", () => {
toast.error("Failed to play preview", {
@@ -54,27 +59,27 @@ export function usePreview() {
});
setLoadingPreview(null);
setPlayingTrack(null);
setCurrentAudio(null);
if (currentPlaybackRef.current?.audio === audio) {
currentPlaybackRef.current.destroy();
currentPlaybackRef.current = null;
}
});
setCurrentAudio(audio);
currentPlaybackRef.current = playback;
await audio.play();
}
catch (error: any) {
catch (error: unknown) {
stopCurrentAudio();
console.error("Preview error:", error);
toast.error("Preview not available", {
description: error?.message || `Could not load preview for "${trackName}"`,
description: error instanceof Error ? error.message : `Could not load preview for "${trackName}"`,
});
setLoadingPreview(null);
setPlayingTrack(null);
}
};
const stopPreview = () => {
if (currentAudio) {
currentAudio.pause();
currentAudio.currentTime = 0;
setCurrentAudio(null);
setPlayingTrack(null);
}
stopCurrentAudio();
setPlayingTrack(null);
};
return {
playPreview,
+29 -28
View File
@@ -10,19 +10,10 @@ export interface ApiSource {
interface SpotiFLACNextSource {
id: string;
name: string;
statusKey?: string;
statusPrefix?: string;
}
type SpotiFLACNextStatusResponse = {
tidal?: string;
qobuz_a?: string;
qobuz_b?: string;
qobuz_c?: string;
deezer_a?: string;
deezer_b?: string;
amazon_a?: string;
amazon_b?: string;
amazon_c?: string;
apple?: string;
};
type SpotiFLACNextStatusResponse = Partial<Record<string, string>>;
export const API_SOURCES: ApiSource[] = [
{ id: "tidal", type: "tidal", name: "Tidal", url: "" },
{ id: "qobuz", type: "qobuz", name: "Qobuz", url: "" },
@@ -30,13 +21,13 @@ export const API_SOURCES: ApiSource[] = [
{ id: "musicbrainz", type: "musicbrainz", name: "MusicBrainz", url: "https://musicbrainz.org" },
];
export const SPOTIFLAC_NEXT_SOURCES: SpotiFLACNextSource[] = [
{ id: "tidal", name: "Tidal" },
{ id: "qobuz", name: "Qobuz" },
{ id: "amazon", name: "Amazon Music" },
{ id: "deezer", name: "Deezer" },
{ id: "apple", name: "Apple Music" },
{ id: "tidal", name: "Tidal", statusKey: "tidal" },
{ id: "qobuz", name: "Qobuz", statusPrefix: "qobuz_" },
{ id: "amazon", name: "Amazon Music", statusPrefix: "amazon_" },
{ id: "deezer", name: "Deezer", statusPrefix: "deezer_" },
{ id: "apple", name: "Apple Music", statusKey: "apple" },
];
const SPOTIFLAC_NEXT_STATUS_URL = "https://status.spotbye.qzz.io/status";
const SPOTIFLAC_NEXT_STATUS_URL = "https://gist.githubusercontent.com/afkarxyz/6e57cd362cbd67f889e3a91a76254a5e/raw";
const SPOTIFLAC_NEXT_MAX_ATTEMPTS = 3;
const SPOTIFLAC_NEXT_RETRY_DELAY_MS = 1200;
type ApiStatusState = {
@@ -70,12 +61,25 @@ async function checkSourceStatus(source: ApiSource): Promise<ApiCheckStatus> {
return "offline";
}
}
function statusFromNextValue(value: string | undefined): ApiCheckStatus {
return value === "up" ? "online" : "offline";
}
function anyNextVariantUp(values: Array<string | undefined>): ApiCheckStatus {
return values.some((value) => value === "up") ? "online" : "offline";
}
function getNextSourceValues(payload: SpotiFLACNextStatusResponse, source: SpotiFLACNextSource): string[] {
if (source.statusKey) {
const value = payload[source.statusKey];
return typeof value === "string" ? [value] : [];
}
if (!source.statusPrefix) {
return [];
}
const values: string[] = [];
for (const [key, value] of Object.entries(payload)) {
if (key.startsWith(source.statusPrefix) && typeof value === "string") {
values.push(value);
}
}
return values;
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
@@ -98,13 +102,10 @@ async function fetchSpotiFLACNextStatusesOnce(): Promise<Record<string, ApiCheck
throw new Error(`SpotiFLAC Next status returned ${response.status}`);
}
const payload = (await response.json()) as SpotiFLACNextStatusResponse;
return {
tidal: statusFromNextValue(payload.tidal),
qobuz: anyNextVariantUp([payload.qobuz_a, payload.qobuz_b, payload.qobuz_c]),
deezer: anyNextVariantUp([payload.deezer_a, payload.deezer_b]),
amazon: anyNextVariantUp([payload.amazon_a, payload.amazon_b, payload.amazon_c]),
apple: statusFromNextValue(payload.apple),
};
return SPOTIFLAC_NEXT_SOURCES.reduce<Record<string, ApiCheckStatus>>((acc, source) => {
acc[source.id] = anyNextVariantUp(getNextSourceValues(payload, source));
return acc;
}, {});
}
async function checkSpotiFLACNextStatuses(): Promise<Record<string, ApiCheckStatus>> {
let lastError: unknown = null;
-3
View File
@@ -13,9 +13,6 @@ export async function fetchSpotifyMetadata(url: string, batch: boolean = true, d
}
export async function downloadTrack(request: DownloadRequest): Promise<DownloadResponse> {
const req = new main.DownloadRequest(request);
if (request.tidal_variant !== undefined) {
(req as any).tidal_variant = request.tidal_variant;
}
if (request.use_single_genre !== undefined) {
(req as any).use_single_genre = request.use_single_genre;
}
+37
View File
@@ -0,0 +1,37 @@
import { getPreviewVolume, PREVIEW_VOLUME_CHANGED_EVENT } from "@/lib/preview";
export interface PreviewPlayback {
audio: HTMLAudioElement;
destroy: () => void;
}
export async function createPreviewPlayback(url: string, volume: number): Promise<PreviewPlayback> {
const audio = new Audio(url);
const applyVolume = (nextVolume: number) => {
if (!Number.isFinite(nextVolume)) {
return;
}
audio.volume = Math.min(1, Math.max(0, nextVolume));
};
applyVolume(volume);
const handleSettingsUpdated = () => {
applyVolume(getPreviewVolume());
};
const handlePreviewVolumeChanged = (event: Event) => {
const nextVolumePercent = (event as CustomEvent<number>).detail;
if (!Number.isFinite(nextVolumePercent)) {
return;
}
applyVolume(nextVolumePercent / 100);
};
window.addEventListener("settingsUpdated", handleSettingsUpdated);
window.addEventListener(PREVIEW_VOLUME_CHANGED_EVENT, handlePreviewVolumeChanged);
return {
audio,
destroy: () => {
window.removeEventListener("settingsUpdated", handleSettingsUpdated);
window.removeEventListener(PREVIEW_VOLUME_CHANGED_EVENT, handlePreviewVolumeChanged);
audio.pause();
audio.removeAttribute("src");
audio.load();
},
};
}
+9
View File
@@ -1 +1,10 @@
import { getSettings } from "@/lib/settings";
export const SPOTIFY_PREVIEW_VOLUME = 1;
export const PREVIEW_VOLUME_CHANGED_EVENT = "previewVolumeChanged";
export function getPreviewVolume(): number {
const previewVolume = getSettings().previewVolume;
if (!Number.isFinite(previewVolume)) {
return SPOTIFY_PREVIEW_VOLUME;
}
return Math.min(1, Math.max(0, previewVolume / 100));
}
+574 -236
View File
@@ -1,15 +1,32 @@
import { GetDefaults, LoadSettings, SaveSettings as SaveToBackend } from "../../wailsjs/go/main/App";
export type FontFamily = "google-sans" | "inter" | "poppins" | "roboto" | "dm-sans" | "plus-jakarta-sans" | "manrope" | "space-grotesk" | "noto-sans" | "nunito-sans" | "figtree" | "raleway" | "public-sans" | "outfit" | "jetbrains-mono" | "geist-sans" | "bricolage-grotesque";
import { GetDefaults, LoadFonts as LoadFontsFromBackend, LoadSettings, SaveFonts as SaveFontsToBackend, SaveSettings as SaveToBackend, } from "../../wailsjs/go/main/App";
export type BuiltInFontFamily = "google-sans" | "inter" | "poppins" | "roboto" | "dm-sans" | "plus-jakarta-sans" | "manrope" | "space-grotesk" | "noto-sans" | "nunito-sans" | "figtree" | "raleway" | "public-sans" | "outfit" | "jetbrains-mono" | "geist-sans" | "bricolage-grotesque";
export type CustomFontFamily = `custom-${string}`;
export type FontFamily = BuiltInFontFamily | CustomFontFamily;
export interface CustomFontOption {
value: CustomFontFamily;
label: string;
fontFamily: string;
url: string;
}
export type FontOption = {
value: FontFamily;
label: string;
fontFamily: string;
url?: string;
};
export type FolderPreset = "none" | "artist" | "album" | "year-album" | "year-artist-album" | "artist-album" | "artist-year-album" | "artist-year-nested-album" | "album-artist" | "album-artist-album" | "album-artist-year-album" | "album-artist-year-nested-album" | "year" | "year-artist" | "custom";
export type FilenamePreset = "title" | "title-artist" | "artist-title" | "track-title" | "track-title-artist" | "track-artist-title" | "title-album-artist" | "track-title-album-artist" | "artist-album-title" | "track-dash-title" | "disc-track-title" | "disc-track-title-artist" | "custom";
export type ExistingFileCheckMode = "filename" | "isrc";
export interface Settings {
downloadPath: string;
downloader: "auto" | "tidal" | "qobuz" | "amazon";
customTidalApi: string;
linkResolver: "songstats" | "songlink";
allowResolverFallback: boolean;
theme: string;
themeMode: "auto" | "light" | "dark";
fontFamily: FontFamily;
customFonts: CustomFontOption[];
folderPreset: FolderPreset;
folderTemplate: string;
filenamePreset: FilenamePreset;
@@ -22,7 +39,6 @@ export interface Settings {
embedLyrics: boolean;
embedMaxQualityCover: boolean;
operatingSystem: "Windows" | "linux/MacOS";
tidalVariant: "tidal" | "alt";
tidalQuality: "LOSSLESS" | "HI_RES_LOSSLESS";
qobuzQuality: "6" | "7" | "27";
amazonQuality: "original";
@@ -32,6 +48,8 @@ export interface Settings {
createPlaylistFolder: boolean;
playlistOwnerFolderName: boolean;
createM3u8File: boolean;
previewVolume: number;
existingFileCheckMode: ExistingFileCheckMode;
useFirstArtistOnly: boolean;
useSingleGenre: boolean;
embedGenre: boolean;
@@ -42,54 +60,105 @@ export const FOLDER_PRESETS: Record<FolderPreset, {
label: string;
template: string;
}> = {
"none": { label: "No Subfolder", template: "" },
"artist": { label: "Artist", template: "{artist}" },
"album": { label: "Album", template: "{album}" },
none: { label: "No Subfolder", template: "" },
artist: { label: "Artist", template: "{artist}" },
album: { label: "Album", template: "{album}" },
"year-album": { label: "[Year] Album", template: "[{year}] {album}" },
"year-artist-album": { label: "[Year] Artist - Album", template: "[{year}] {artist} - {album}" },
"year-artist-album": {
label: "[Year] Artist - Album",
template: "[{year}] {artist} - {album}",
},
"artist-album": { label: "Artist / Album", template: "{artist}/{album}" },
"artist-year-album": { label: "Artist / [Year] Album", template: "{artist}/[{year}] {album}" },
"artist-year-nested-album": { label: "Artist / Year / Album", template: "{artist}/{year}/{album}" },
"artist-year-album": {
label: "Artist / [Year] Album",
template: "{artist}/[{year}] {album}",
},
"artist-year-nested-album": {
label: "Artist / Year / Album",
template: "{artist}/{year}/{album}",
},
"album-artist": { label: "Album Artist", template: "{album_artist}" },
"album-artist-album": { label: "Album Artist / Album", template: "{album_artist}/{album}" },
"album-artist-year-album": { label: "Album Artist / [Year] Album", template: "{album_artist}/[{year}] {album}" },
"album-artist-year-nested-album": { label: "Album Artist / Year / Album", template: "{album_artist}/{year}/{album}" },
"year": { label: "Year", template: "{year}" },
"album-artist-album": {
label: "Album Artist / Album",
template: "{album_artist}/{album}",
},
"album-artist-year-album": {
label: "Album Artist / [Year] Album",
template: "{album_artist}/[{year}] {album}",
},
"album-artist-year-nested-album": {
label: "Album Artist / Year / Album",
template: "{album_artist}/{year}/{album}",
},
year: { label: "Year", template: "{year}" },
"year-artist": { label: "Year / Artist", template: "{year}/{artist}" },
"custom": { label: "Custom...", template: "{artist}/{album}" },
custom: { label: "Custom...", template: "{artist}/{album}" },
};
export const FILENAME_PRESETS: Record<FilenamePreset, {
label: string;
template: string;
}> = {
"title": { label: "Title", template: "{title}" },
title: { label: "Title", template: "{title}" },
"title-artist": { label: "Title - Artist", template: "{title} - {artist}" },
"artist-title": { label: "Artist - Title", template: "{artist} - {title}" },
"track-title": { label: "Track. Title", template: "{track}. {title}" },
"track-title-artist": { label: "Track. Title - Artist", template: "{track}. {title} - {artist}" },
"track-artist-title": { label: "Track. Artist - Title", template: "{track}. {artist} - {title}" },
"title-album-artist": { label: "Title - Album Artist", template: "{title} - {album_artist}" },
"track-title-album-artist": { label: "Track. Title - Album Artist", template: "{track}. {title} - {album_artist}" },
"artist-album-title": { label: "Artist - Album - Title", template: "{artist} - {album} - {title}" },
"track-title-artist": {
label: "Track. Title - Artist",
template: "{track}. {title} - {artist}",
},
"track-artist-title": {
label: "Track. Artist - Title",
template: "{track}. {artist} - {title}",
},
"title-album-artist": {
label: "Title - Album Artist",
template: "{title} - {album_artist}",
},
"track-title-album-artist": {
label: "Track. Title - Album Artist",
template: "{track}. {title} - {album_artist}",
},
"artist-album-title": {
label: "Artist - Album - Title",
template: "{artist} - {album} - {title}",
},
"track-dash-title": { label: "Track - Title", template: "{track} - {title}" },
"disc-track-title": { label: "Disc-Track. Title", template: "{disc}-{track}. {title}" },
"disc-track-title-artist": { label: "Disc-Track. Title - Artist", template: "{disc}-{track}. {title} - {artist}" },
"custom": { label: "Custom...", template: "{title} - {artist}" },
"disc-track-title": {
label: "Disc-Track. Title",
template: "{disc}-{track}. {title}",
},
"disc-track-title-artist": {
label: "Disc-Track. Title - Artist",
template: "{disc}-{track}. {title} - {artist}",
},
custom: { label: "Custom...", template: "{title} - {artist}" },
};
export const TEMPLATE_VARIABLES = [
{ key: "{title}", description: "Track title", example: "Shake It Off" },
{ key: "{artist}", description: "Track artist", example: "Taylor Swift" },
{ key: "{album}", description: "Album name", example: "1989" },
{ key: "{album_artist}", description: "Album artist", example: "Taylor Swift" },
{
key: "{album_artist}",
description: "Album artist",
example: "Taylor Swift",
},
{ key: "{track}", description: "Track number", example: "01" },
{ key: "{disc}", description: "Disc number", example: "1" },
{ key: "{year}", description: "Release year", example: "2014" },
{ key: "{date}", description: "Release date (YYYY-MM-DD)", example: "2014-10-27" },
{ key: "{isrc}", description: "Track ISRC", example: "USUM71412345" },
{
key: "{date}",
description: "Release date (YYYY-MM-DD)",
example: "2014-10-27",
},
{
key: "{isrc}",
description: "Track ISRC",
example: "USUM71412345",
},
];
function detectOS(): "Windows" | "linux/MacOS" {
const platform = window.navigator.platform.toLowerCase();
if (platform.includes('win')) {
if (platform.includes("win")) {
return "Windows";
}
return "linux/MacOS";
@@ -97,11 +166,13 @@ function detectOS(): "Windows" | "linux/MacOS" {
export const DEFAULT_SETTINGS: Settings = {
downloadPath: "",
downloader: "auto",
customTidalApi: "",
linkResolver: "songlink",
allowResolverFallback: true,
theme: "yellow",
themeMode: "auto",
fontFamily: "google-sans",
customFonts: [],
folderPreset: "none",
folderTemplate: "",
filenamePreset: "title-artist",
@@ -111,7 +182,6 @@ export const DEFAULT_SETTINGS: Settings = {
embedLyrics: false,
embedMaxQualityCover: false,
operatingSystem: detectOS(),
tidalVariant: "tidal",
tidalQuality: "LOSSLESS",
qobuzQuality: "6",
amazonQuality: "original",
@@ -121,42 +191,461 @@ export const DEFAULT_SETTINGS: Settings = {
createPlaylistFolder: true,
playlistOwnerFolderName: false,
createM3u8File: false,
previewVolume: 100,
existingFileCheckMode: "filename",
useFirstArtistOnly: false,
useSingleGenre: false,
embedGenre: false,
redownloadWithSuffix: false,
separator: "semicolon"
separator: "semicolon",
};
export const FONT_OPTIONS: {
value: FontFamily;
label: string;
fontFamily: string;
}[] = [
{ value: "bricolage-grotesque", label: "Bricolage Grotesque", fontFamily: '"Bricolage Grotesque", system-ui, sans-serif' },
{ value: "dm-sans", label: "DM Sans", fontFamily: '"DM Sans", system-ui, sans-serif' },
{ value: "figtree", label: "Figtree", fontFamily: '"Figtree", system-ui, sans-serif' },
{ value: "geist-sans", label: "Geist Sans", fontFamily: '"Geist", system-ui, sans-serif' },
{ value: "google-sans", label: "Google Sans", fontFamily: '"Google Sans", system-ui, sans-serif' },
{ value: "inter", label: "Inter", fontFamily: '"Inter", system-ui, sans-serif' },
{ value: "jetbrains-mono", label: "JetBrains Mono", fontFamily: '"JetBrains Mono", ui-monospace, monospace' },
{ value: "manrope", label: "Manrope", fontFamily: '"Manrope", system-ui, sans-serif' },
{ value: "noto-sans", label: "Noto Sans", fontFamily: '"Noto Sans", system-ui, sans-serif' },
{ value: "nunito-sans", label: "Nunito Sans", fontFamily: '"Nunito Sans", system-ui, sans-serif' },
{ value: "outfit", label: "Outfit", fontFamily: '"Outfit", system-ui, sans-serif' },
{ value: "plus-jakarta-sans", label: "Plus Jakarta Sans", fontFamily: '"Plus Jakarta Sans", system-ui, sans-serif' },
{ value: "poppins", label: "Poppins", fontFamily: '"Poppins", system-ui, sans-serif' },
{ value: "public-sans", label: "Public Sans", fontFamily: '"Public Sans", system-ui, sans-serif' },
{ value: "raleway", label: "Raleway", fontFamily: '"Raleway", system-ui, sans-serif' },
{ value: "roboto", label: "Roboto", fontFamily: '"Roboto", system-ui, sans-serif' },
{ value: "space-grotesk", label: "Space Grotesk", fontFamily: '"Space Grotesk", system-ui, sans-serif' },
export const FONT_OPTIONS: FontOption[] = [
{
value: "bricolage-grotesque",
label: "Bricolage Grotesque",
fontFamily: '"Bricolage Grotesque", system-ui, sans-serif',
},
{
value: "dm-sans",
label: "DM Sans",
fontFamily: '"DM Sans", system-ui, sans-serif',
},
{
value: "figtree",
label: "Figtree",
fontFamily: '"Figtree", system-ui, sans-serif',
},
{
value: "geist-sans",
label: "Geist Sans",
fontFamily: '"Geist", system-ui, sans-serif',
},
{
value: "google-sans",
label: "Google Sans",
fontFamily: '"Google Sans", system-ui, sans-serif',
},
{
value: "inter",
label: "Inter",
fontFamily: '"Inter", system-ui, sans-serif',
},
{
value: "jetbrains-mono",
label: "JetBrains Mono",
fontFamily: '"JetBrains Mono", ui-monospace, monospace',
},
{
value: "manrope",
label: "Manrope",
fontFamily: '"Manrope", system-ui, sans-serif',
},
{
value: "noto-sans",
label: "Noto Sans",
fontFamily: '"Noto Sans", system-ui, sans-serif',
},
{
value: "nunito-sans",
label: "Nunito Sans",
fontFamily: '"Nunito Sans", system-ui, sans-serif',
},
{
value: "outfit",
label: "Outfit",
fontFamily: '"Outfit", system-ui, sans-serif',
},
{
value: "plus-jakarta-sans",
label: "Plus Jakarta Sans",
fontFamily: '"Plus Jakarta Sans", system-ui, sans-serif',
},
{
value: "poppins",
label: "Poppins",
fontFamily: '"Poppins", system-ui, sans-serif',
},
{
value: "public-sans",
label: "Public Sans",
fontFamily: '"Public Sans", system-ui, sans-serif',
},
{
value: "raleway",
label: "Raleway",
fontFamily: '"Raleway", system-ui, sans-serif',
},
{
value: "roboto",
label: "Roboto",
fontFamily: '"Roboto", system-ui, sans-serif',
},
{
value: "space-grotesk",
label: "Space Grotesk",
fontFamily: '"Space Grotesk", system-ui, sans-serif',
},
];
export function applyFont(fontFamily: FontFamily): void {
const font = FONT_OPTIONS.find(f => f.value === fontFamily);
const BUILT_IN_FONT_VALUES = new Set(FONT_OPTIONS.map((font) => font.value));
const GOOGLE_FONT_LINK_ID_PREFIX = "spotiflac-custom-font-";
const GOOGLE_FONTS_CSS_HOST = "fonts.googleapis.com";
const GOOGLE_FONTS_SPECIMEN_HOST = "fonts.google.com";
const SETTINGS_KEY = "spotiflac-settings";
let cachedSettings: Settings | null = null;
type SettingsPayload = Partial<Settings> & {
darkMode?: boolean;
[key: string]: unknown;
};
const KNOWN_SETTINGS_KEYS = Object.keys(DEFAULT_SETTINGS) as Array<keyof Settings>;
function extractGoogleFontInputUrl(input: string): string {
const trimmed = input.trim();
const hrefMatch = trimmed.match(/\bhref=["']([^"']+)["']/i);
if (hrefMatch?.[1]) {
return hrefMatch[1];
}
const importMatch = trimmed.match(/@import\s+url\(["']?([^"')]+)["']?\)/i);
if (importMatch?.[1]) {
return importMatch[1];
}
return trimmed;
}
function coerceGoogleFontUrl(rawUrl: string): string {
const trimmed = rawUrl.trim();
if (/^https?:\/\//i.test(trimmed)) {
return trimmed;
}
if (/^(fonts\.googleapis\.com|fonts\.google\.com)\//i.test(trimmed)) {
return `https://${trimmed}`;
}
return trimmed;
}
function normalizeFontLabel(label: string): string {
return label.replace(/\+/g, " ").replace(/\s+/g, " ").trim();
}
function slugifyFontLabel(label: string): string {
return label.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "font";
}
function toFontFamilyCss(label: string): string {
const escapedLabel = label.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
return `"${escapedLabel}", system-ui, sans-serif`;
}
function buildGoogleFontsCssUrl(label: string): string {
const url = new URL("https://fonts.googleapis.com/css2");
url.searchParams.set("family", label);
url.searchParams.set("display", "swap");
return url.toString();
}
function extractSpecimenFontLabel(parsed: URL): string {
const segments = parsed.pathname.split("/").filter(Boolean);
const specimenIndex = segments.findIndex((segment) => segment.toLowerCase() === "specimen");
const specimenName = specimenIndex >= 0 ? segments[specimenIndex + 1] : "";
return normalizeFontLabel(decodeURIComponent(specimenName || ""));
}
function normalizeGoogleFontCssUrl(rawUrl: string): string | null {
try {
const parsed = new URL(coerceGoogleFontUrl(extractGoogleFontInputUrl(rawUrl)));
if (parsed.protocol !== "https:") {
return null;
}
if (parsed.hostname === GOOGLE_FONTS_SPECIMEN_HOST) {
const label = extractSpecimenFontLabel(parsed);
return label ? buildGoogleFontsCssUrl(label) : null;
}
if (parsed.hostname !== GOOGLE_FONTS_CSS_HOST ||
(parsed.pathname !== "/css" && parsed.pathname !== "/css2")) {
return null;
}
if (parsed.searchParams.getAll("family").length === 0) {
return null;
}
if (!parsed.searchParams.has("display")) {
parsed.searchParams.set("display", "swap");
}
return parsed.toString();
}
catch {
return null;
}
}
export function parseGoogleFontUrl(rawUrl: string): CustomFontOption | null {
const normalizedUrl = normalizeGoogleFontCssUrl(rawUrl);
if (!normalizedUrl) {
return null;
}
const parsed = new URL(normalizedUrl);
const family = parsed.searchParams.getAll("family")[0];
const label = normalizeFontLabel((family || "").split(":")[0] || "");
if (!label) {
return null;
}
return {
value: `custom-${slugifyFontLabel(label)}` as CustomFontFamily,
label,
fontFamily: toFontFamilyCss(label),
url: normalizedUrl,
};
}
function normalizeCustomFonts(customFonts: unknown): CustomFontOption[] {
if (!Array.isArray(customFonts)) {
return [];
}
const normalizedFonts: CustomFontOption[] = [];
const seenValues = new Set<string>();
const seenUrls = new Set<string>();
for (const item of customFonts) {
if (!item || typeof item !== "object") {
continue;
}
const rawUrl = (item as {
url?: unknown;
}).url;
if (typeof rawUrl !== "string") {
continue;
}
const parsed = parseGoogleFontUrl(rawUrl);
if (!parsed || seenValues.has(parsed.value) || seenUrls.has(parsed.url)) {
continue;
}
seenValues.add(parsed.value);
seenUrls.add(parsed.url);
normalizedFonts.push(parsed);
}
return normalizedFonts;
}
function normalizeFontFamily(fontFamily: unknown, customFonts: CustomFontOption[]): FontFamily {
if (typeof fontFamily !== "string") {
return DEFAULT_SETTINGS.fontFamily;
}
if (BUILT_IN_FONT_VALUES.has(fontFamily as BuiltInFontFamily)) {
return fontFamily as BuiltInFontFamily;
}
const customFont = customFonts.find((font) => font.value === fontFamily);
return customFont ? customFont.value : DEFAULT_SETTINGS.fontFamily;
}
export function getFontOptions(customFonts: CustomFontOption[] = []): FontOption[] {
return [...FONT_OPTIONS, ...normalizeCustomFonts(customFonts)];
}
export function loadGoogleFontUrl(url: string, id = `${GOOGLE_FONT_LINK_ID_PREFIX}preview`): void {
const normalizedUrl = normalizeGoogleFontCssUrl(url);
if (!normalizedUrl) {
return;
}
let link = document.getElementById(id) as HTMLLinkElement | null;
if (!link) {
link = document.createElement("link");
link.id = id;
link.rel = "stylesheet";
document.head.appendChild(link);
}
if (link.href !== normalizedUrl) {
link.href = normalizedUrl;
}
}
function loadCustomFontStylesheets(customFonts: CustomFontOption[]): void {
for (const font of normalizeCustomFonts(customFonts)) {
loadGoogleFontUrl(font.url, `${GOOGLE_FONT_LINK_ID_PREFIX}${font.value}`);
}
}
export function applyFont(fontFamily: FontFamily, customFonts: CustomFontOption[] = []): void {
const fontOptions = getFontOptions(customFonts);
loadCustomFontStylesheets(customFonts);
const font = fontOptions.find((option) => option.value === fontFamily) ||
FONT_OPTIONS.find((option) => option.value === DEFAULT_SETTINGS.fontFamily);
if (font) {
document.documentElement.style.setProperty('--font-sans', font.fontFamily);
document.documentElement.style.setProperty("--font-sans", font.fontFamily);
document.body.style.fontFamily = font.fontFamily;
}
}
async function persistCustomFontsInternal(customFonts: CustomFontOption[]): Promise<CustomFontOption[]> {
const normalizedFonts = normalizeCustomFonts(customFonts);
await SaveFontsToBackend(normalizedFonts as unknown as Array<Record<string, unknown>>);
if (cachedSettings) {
cachedSettings = toNormalizedSettings({
...cachedSettings,
customFonts: normalizedFonts,
});
localStorage.setItem(SETTINGS_KEY, JSON.stringify(cachedSettings));
window.dispatchEvent(new CustomEvent("settingsUpdated", { detail: cachedSettings }));
}
return normalizedFonts;
}
async function loadStoredCustomFonts(fallbackFonts?: unknown): Promise<CustomFontOption[]> {
try {
const storedFonts = await LoadFontsFromBackend();
if (storedFonts !== null) {
return normalizeCustomFonts(storedFonts);
}
}
catch (error) {
console.error("Failed to load custom fonts:", error);
}
const migratedFonts = normalizeCustomFonts(fallbackFonts);
if (migratedFonts.length > 0) {
try {
return await persistCustomFontsInternal(migratedFonts);
}
catch (error) {
console.error("Failed to migrate custom fonts:", error);
}
}
return migratedFonts;
}
export async function loadCustomFonts(): Promise<CustomFontOption[]> {
return loadStoredCustomFonts(getSettings().customFonts);
}
export async function saveCustomFonts(customFonts: CustomFontOption[]): Promise<CustomFontOption[]> {
return persistCustomFontsInternal(customFonts);
}
function keepKnownSettings(settings: SettingsPayload): SettingsPayload {
const normalized: Record<string, unknown> = {};
for (const key of KNOWN_SETTINGS_KEYS) {
if (key in settings) {
normalized[key] = settings[key];
}
}
return normalized as SettingsPayload;
}
function normalizePreviewVolume(volume: unknown): number {
const parsed = typeof volume === "number"
? volume
: typeof volume === "string"
? Number.parseFloat(volume)
: Number.NaN;
if (!Number.isFinite(parsed)) {
return DEFAULT_SETTINGS.previewVolume;
}
return Math.min(100, Math.max(0, Math.round(parsed)));
}
function normalizeCustomTidalApi(value: unknown): string {
return typeof value === "string"
? value.trim().replace(/\/+$/g, "")
: "";
}
function normalizeExistingFileCheckMode(mode: unknown): ExistingFileCheckMode {
switch (typeof mode === "string" ? mode.trim().toLowerCase() : "") {
case "isrc":
case "upc":
return "isrc";
default:
return "filename";
}
}
function normalizeSettingsPayload(settings: SettingsPayload): SettingsPayload {
const normalized: SettingsPayload = { ...settings };
if ("darkMode" in normalized && !("themeMode" in normalized)) {
normalized.themeMode = normalized.darkMode ? "dark" : "light";
delete normalized.darkMode;
}
if (!("folderPreset" in normalized) &&
("artistSubfolder" in normalized || "albumSubfolder" in normalized)) {
const hasArtist = Boolean(normalized.artistSubfolder);
const hasAlbum = Boolean(normalized.albumSubfolder);
if (hasArtist && hasAlbum) {
normalized.folderPreset = "artist-album";
normalized.folderTemplate = "{artist}/{album}";
}
else if (hasArtist) {
normalized.folderPreset = "artist";
normalized.folderTemplate = "{artist}";
}
else if (hasAlbum) {
normalized.folderPreset = "album";
normalized.folderTemplate = "{album}";
}
else {
normalized.folderPreset = "none";
normalized.folderTemplate = "";
}
}
if (!("filenamePreset" in normalized) && "filenameFormat" in normalized) {
const format = normalized.filenameFormat;
if (format === "title-artist") {
normalized.filenamePreset = "artist-title";
normalized.filenameTemplate = "{artist} - {title}";
}
else if (format === "artist-title") {
normalized.filenamePreset = "artist-title";
normalized.filenameTemplate = "{artist} - {title}";
}
else {
normalized.filenamePreset = "title";
normalized.filenameTemplate = "{title}";
}
}
delete normalized.tidalVariant;
if (!("tidalQuality" in normalized)) {
normalized.tidalQuality = "LOSSLESS";
}
if (!("qobuzQuality" in normalized)) {
normalized.qobuzQuality = "6";
}
if (!("amazonQuality" in normalized)) {
normalized.amazonQuality = "original";
}
if (!("autoOrder" in normalized)) {
normalized.autoOrder = "tidal-qobuz-amazon";
}
if (!("autoQuality" in normalized)) {
normalized.autoQuality = "16";
}
normalized.customTidalApi = normalizeCustomTidalApi(normalized.customTidalApi);
if (!("allowFallback" in normalized)) {
normalized.allowFallback = true;
}
if (!("linkResolver" in normalized)) {
normalized.linkResolver = "songlink";
}
if (!("allowResolverFallback" in normalized)) {
normalized.allowResolverFallback = true;
}
if (!("createPlaylistFolder" in normalized)) {
normalized.createPlaylistFolder = true;
}
if (!("playlistOwnerFolderName" in normalized)) {
normalized.playlistOwnerFolderName = false;
}
if (!("createM3u8File" in normalized)) {
normalized.createM3u8File = false;
}
normalized.previewVolume = normalizePreviewVolume(normalized.previewVolume);
normalized.existingFileCheckMode = normalizeExistingFileCheckMode(normalized.existingFileCheckMode);
if (!("useFirstArtistOnly" in normalized)) {
normalized.useFirstArtistOnly = false;
}
if (!("useSingleGenre" in normalized)) {
normalized.useSingleGenre = false;
}
if (!("embedGenre" in normalized)) {
normalized.embedGenre = false;
}
if (!("separator" in normalized)) {
normalized.separator = "semicolon";
}
if (!("redownloadWithSuffix" in normalized)) {
normalized.redownloadWithSuffix = false;
}
normalized.operatingSystem = detectOS();
const normalizedCustomFonts = normalizeCustomFonts(normalized.customFonts);
normalized.customFonts = normalizedCustomFonts;
normalized.fontFamily = normalizeFontFamily(normalized.fontFamily, normalizedCustomFonts);
return normalized;
}
function toNormalizedSettings(settings: SettingsPayload): Settings {
return {
...DEFAULT_SETTINGS,
...keepKnownSettings(normalizeSettingsPayload(settings)),
} as Settings;
}
async function persistSettingsInternal(settings: Settings, notify = true): Promise<void> {
cachedSettings = settings;
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
const settingsForBackend = { ...settings } as Record<string, unknown>;
delete settingsForBackend.customFonts;
await SaveToBackend(settingsForBackend);
if (notify) {
window.dispatchEvent(new CustomEvent("settingsUpdated", { detail: settings }));
}
}
async function fetchDefaultPath(): Promise<string> {
try {
const data = await GetDefaults();
@@ -167,90 +656,11 @@ async function fetchDefaultPath(): Promise<string> {
return "";
}
}
const SETTINGS_KEY = "spotiflac-settings";
let cachedSettings: Settings | null = null;
function getSettingsFromLocalStorage(): Settings {
try {
const stored = localStorage.getItem(SETTINGS_KEY);
if (stored) {
const parsed = JSON.parse(stored);
if ('darkMode' in parsed && !('themeMode' in parsed)) {
parsed.themeMode = parsed.darkMode ? 'dark' : 'light';
delete parsed.darkMode;
}
if (!('folderPreset' in parsed) && ('artistSubfolder' in parsed || 'albumSubfolder' in parsed)) {
const hasArtist = parsed.artistSubfolder;
const hasAlbum = parsed.albumSubfolder;
if (hasArtist && hasAlbum) {
parsed.folderPreset = "artist-album";
parsed.folderTemplate = "{artist}/{album}";
}
else if (hasArtist) {
parsed.folderPreset = "artist";
parsed.folderTemplate = "{artist}";
}
else if (hasAlbum) {
parsed.folderPreset = "album";
parsed.folderTemplate = "{album}";
}
else {
parsed.folderPreset = "none";
parsed.folderTemplate = "";
}
}
if (!('filenamePreset' in parsed) && 'filenameFormat' in parsed) {
const format = parsed.filenameFormat;
if (format === "title-artist") {
parsed.filenamePreset = "artist-title";
parsed.filenameTemplate = "{artist} - {title}";
}
else if (format === "artist-title") {
parsed.filenamePreset = "artist-title";
parsed.filenameTemplate = "{artist} - {title}";
}
else {
parsed.filenamePreset = "title";
parsed.filenameTemplate = "{title}";
}
}
parsed.operatingSystem = detectOS();
if (!('tidalQuality' in parsed)) {
parsed.tidalQuality = "LOSSLESS";
}
if (!('tidalVariant' in parsed)) {
parsed.tidalVariant = "tidal";
}
if (!('qobuzQuality' in parsed)) {
parsed.qobuzQuality = "6";
}
if (!('amazonQuality' in parsed)) {
parsed.amazonQuality = "original";
}
if (!('autoOrder' in parsed)) {
parsed.autoOrder = "tidal-qobuz-amazon";
}
if (!('autoQuality' in parsed)) {
parsed.autoQuality = "16";
}
if (!('allowFallback' in parsed)) {
parsed.allowFallback = true;
}
if (!('linkResolver' in parsed)) {
parsed.linkResolver = "songlink";
}
if (!('allowResolverFallback' in parsed)) {
parsed.allowResolverFallback = true;
}
if (!('playlistOwnerFolderName' in parsed)) {
parsed.playlistOwnerFolderName = false;
}
if (!('separator' in parsed)) {
parsed.separator = "semicolon";
}
if (!('redownloadWithSuffix' in parsed)) {
parsed.redownloadWithSuffix = false;
}
return { ...DEFAULT_SETTINGS, ...parsed };
return toNormalizedSettings(JSON.parse(stored) as SettingsPayload);
}
}
catch (error) {
@@ -259,108 +669,25 @@ function getSettingsFromLocalStorage(): Settings {
return DEFAULT_SETTINGS;
}
export function getSettings(): Settings {
if (cachedSettings)
if (cachedSettings) {
return cachedSettings;
}
return getSettingsFromLocalStorage();
}
export async function loadSettings(): Promise<Settings> {
try {
const backendSettings = await LoadSettings();
if (backendSettings) {
const parsed = backendSettings as any;
if ('darkMode' in parsed && !('themeMode' in parsed)) {
parsed.themeMode = parsed.darkMode ? 'dark' : 'light';
delete parsed.darkMode;
const parsed = backendSettings as SettingsPayload;
const customFonts = await loadStoredCustomFonts(parsed.customFonts);
cachedSettings = toNormalizedSettings({
...parsed,
customFonts,
});
if ("customFonts" in parsed) {
await persistSettingsInternal(cachedSettings, false);
}
if (!('folderPreset' in parsed) && ('artistSubfolder' in parsed || 'albumSubfolder' in parsed)) {
const hasArtist = parsed.artistSubfolder;
const hasAlbum = parsed.albumSubfolder;
if (hasArtist && hasAlbum) {
parsed.folderPreset = "artist-album";
parsed.folderTemplate = "{artist}/{album}";
}
else if (hasArtist) {
parsed.folderPreset = "artist";
parsed.folderTemplate = "{artist}";
}
else if (hasAlbum) {
parsed.folderPreset = "album";
parsed.folderTemplate = "{album}";
}
else {
parsed.folderPreset = "none";
parsed.folderTemplate = "";
}
}
if (!('filenamePreset' in parsed) && 'filenameFormat' in parsed) {
const format = parsed.filenameFormat;
if (format === "title-artist") {
parsed.filenamePreset = "artist-title";
parsed.filenameTemplate = "{artist} - {title}";
}
else if (format === "artist-title") {
parsed.filenamePreset = "artist-title";
parsed.filenameTemplate = "{artist} - {title}";
}
else {
parsed.filenamePreset = "title";
parsed.filenameTemplate = "{title}";
}
}
parsed.operatingSystem = detectOS();
if (!('tidalQuality' in parsed)) {
parsed.tidalQuality = "LOSSLESS";
}
if (!('tidalVariant' in parsed)) {
parsed.tidalVariant = "tidal";
}
if (!('qobuzQuality' in parsed)) {
parsed.qobuzQuality = "6";
}
if (!('amazonQuality' in parsed)) {
parsed.amazonQuality = "original";
}
if (!('autoOrder' in parsed)) {
parsed.autoOrder = "tidal-qobuz-amazon";
}
if (!('autoQuality' in parsed)) {
parsed.autoQuality = "16";
}
if (!('allowFallback' in parsed)) {
parsed.allowFallback = true;
}
if (!('linkResolver' in parsed)) {
parsed.linkResolver = "songlink";
}
if (!('allowResolverFallback' in parsed)) {
parsed.allowResolverFallback = true;
}
if (!('createPlaylistFolder' in parsed)) {
parsed.createPlaylistFolder = true;
}
if (!('playlistOwnerFolderName' in parsed)) {
parsed.playlistOwnerFolderName = false;
}
if (!('createM3u8File' in parsed)) {
parsed.createM3u8File = false;
}
if (!('useFirstArtistOnly' in parsed)) {
parsed.useFirstArtistOnly = false;
}
if (!('useSingleGenre' in parsed)) {
parsed.useSingleGenre = false;
}
if (!('embedGenre' in parsed)) {
parsed.embedGenre = false;
}
if (!('separator' in parsed)) {
parsed.separator = "semicolon";
}
if (!('redownloadWithSuffix' in parsed)) {
parsed.redownloadWithSuffix = false;
}
cachedSettings = { ...DEFAULT_SETTINGS, ...parsed };
return cachedSettings!;
return cachedSettings;
}
}
catch (error) {
@@ -368,12 +695,19 @@ export async function loadSettings(): Promise<Settings> {
}
const local = getSettingsFromLocalStorage();
try {
await SaveToBackend(local as any);
cachedSettings = local;
const customFonts = await loadStoredCustomFonts(local.customFonts);
const localWithFonts = toNormalizedSettings({
...local,
customFonts,
});
await persistSettingsInternal(localWithFonts, false);
cachedSettings = localWithFonts;
return localWithFonts;
}
catch (error) {
console.error("Failed to migrate settings to backend:", error);
}
cachedSettings = local;
return local;
}
export interface TemplateData {
@@ -389,8 +723,9 @@ export interface TemplateData {
playlist?: string;
}
export function parseTemplate(template: string, data: TemplateData): string {
if (!template)
if (!template) {
return "";
}
let result = template;
result = result.replace(/\{title\}/g, data.title || "Unknown Title");
result = result.replace(/\{artist\}/g, data.artist || "Unknown Artist");
@@ -414,10 +749,8 @@ export async function getSettingsWithDefaults(): Promise<Settings> {
}
export async function saveSettings(settings: Settings): Promise<void> {
try {
cachedSettings = settings;
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
await SaveToBackend(settings as any);
window.dispatchEvent(new CustomEvent('settingsUpdated', { detail: settings }));
const normalizedSettings = toNormalizedSettings(settings as SettingsPayload);
await persistSettingsInternal(normalizedSettings);
}
catch (error) {
console.error("Failed to save settings:", error);
@@ -431,7 +764,12 @@ export async function updateSettings(partial: Partial<Settings>): Promise<Settin
}
export async function resetToDefaultSettings(): Promise<Settings> {
const defaultPath = await fetchDefaultPath();
const defaultSettings = { ...DEFAULT_SETTINGS, downloadPath: defaultPath };
const customFonts = await loadCustomFonts();
const defaultSettings = {
...DEFAULT_SETTINGS,
downloadPath: defaultPath,
customFonts,
};
await saveSettings(defaultSettings);
return defaultSettings;
}
-1
View File
@@ -120,7 +120,6 @@ export interface DownloadRequest {
release_date?: string;
cover_url?: string;
tidal_api_url?: string;
tidal_variant?: "tidal" | "alt";
output_dir?: string;
audio_format?: string;
folder_name?: string;
+1 -1
View File
@@ -12,7 +12,7 @@
},
"info": {
"productName": "SpotiFLAC",
"productVersion": "7.1.5",
"productVersion": "7.1.6",
"copyright": "© 2026 afkarxyz"
},
"wailsjsdir": "./frontend",