Compare commits

..

77 Commits

Author SHA1 Message Date
afkarxyz 2f78f7e7c7 .cleanup 2026-04-19 23:14:12 +07:00
afkarxyz 7c52c2d9b4 .tidal alt 2026-04-19 23:12:06 +07:00
afkarxyz a24ca370eb .refine status check 2026-04-19 22:35:03 +07:00
afkarxyz 3af9327a3d .tidal gist url 2026-04-19 22:15:49 +07:00
afkarxyz a3e780587b .manual check 2026-04-19 21:54:56 +07:00
afkarxyz 043f3f07f3 .refine global scrollbar 2026-04-19 21:48:25 +07:00
afkarxyz bcea7a00bd .remove spotidownloader 2026-04-19 21:41:56 +07:00
afkarxyz e74808fb07 .update url 2026-04-18 07:59:13 +07:00
afkarxyz 17a75ea278 .arm64 linux 2026-04-18 07:52:20 +07:00
afkarxyz d6907641ed .revert build 2026-04-18 07:40:34 +07:00
afkarxyz e9b6b02db1 .readme 2026-04-14 07:36:00 +07:00
afkarxyz a9c52e7b6d .cleanup 2026-04-14 07:28:39 +07:00
afkarxyz ce1e6cc65a .build rename 2026-04-14 07:25:16 +07:00
afkarxyz f75081780e .unified status check 2026-04-14 07:14:18 +07:00
afkarxyz c0c1348c3f .skip off musicbrainz 2026-04-14 06:38:02 +07:00
afkarxyz f123caf5b0 .status icons update 2026-04-14 06:35:18 +07:00
afkarxyz 4c5bba73ce .rate limit musicbrainz 2026-04-14 06:06:12 +07:00
afkarxyz 1858fd6f12 .update ffmpeg + linux arm build 2026-04-14 05:49:23 +07:00
afkarxyz 42d25abe0c .rename 2026-04-13 23:30:43 +07:00
afkarxyz 927aad30e7 .move website below network info 2026-04-13 23:01:14 +07:00
afkarxyz 7320cfb6ca .failed fetch pop up 2026-04-13 23:00:10 +07:00
afkarxyz d85d3174e9 .refine ip info 2026-04-13 22:57:34 +07:00
afkarxyz eda188d4b0 .ip detection 2026-04-13 22:56:57 +07:00
afkarxyz 7997f7e264 .remove spotfetch api 2026-04-13 22:50:45 +07:00
afkarxyz e23fa2a48e .unified totp 2026-04-13 22:43:35 +07:00
afkarxyz 5a3f819cef .upc metadata 2026-04-13 22:39:58 +07:00
afkarxyz 66e3f0e572 .improved recent fetches 2026-04-13 22:18:08 +07:00
afkarxyz 2684bc54bd .improve fetch track list info 2026-04-13 22:07:34 +07:00
afkarxyz db8f82aa17 .redownlaod with suffix, isrc variable 2026-04-13 21:53:47 +07:00
afkarxyz 7792a69d33 .add composer and fix multiple value tag 2026-04-13 21:35:55 +07:00
afkarxyz e79622751d .clickable variables 2026-04-13 21:17:07 +07:00
afkarxyz 1b00badd93 .playlist owner folder name 2026-04-13 20:56:24 +07:00
afkarxyz 24d640443a .refine check availibility 2026-04-13 20:43:37 +07:00
afkarxyz 967feb93e1 .fix qobuz api 2026-04-13 20:29:19 +07:00
afkarxyz 475596d934 .readme 2026-04-02 18:38:49 +07:00
afkarxyz 0d42bc3877 .time ago 2026-04-02 18:34:48 +07:00
afkarxyz 7f12b76fd9 .cleanup 2026-04-02 17:41:25 +07:00
afkarxyz 99f5e4e8b3 .reorder layout 2026-04-02 16:28:21 +07:00
afkarxyz 2d2ceac569 .dropdown region songlink 2026-04-02 16:22:14 +07:00
afkarxyz 5fa9da8e23 .refine about page 2026-04-02 16:10:28 +07:00
afkarxyz 0237895603 .refine about page 2026-04-02 15:46:35 +07:00
afkarxyz fc5bda3b26 .url metadata 2026-04-02 15:30:55 +07:00
afkarxyz af72ca0d01 .fix qobuz check 2026-04-02 15:22:23 +07:00
afkarxyz 42278aa1f3 .songlink default 2026-04-02 14:53:46 +07:00
afkarxyz 1128b0245f .deezer url log 2026-04-02 14:48:52 +07:00
afkarxyz 460405a437 .isrc finder fallback 2026-04-02 12:11:56 +07:00
afkarxyz 4b3bf1cf48 .cleanup 2026-04-02 11:45:42 +07:00
afkarxyz 41eda2d230 .batch audio quality analyzer 2026-04-02 11:30:00 +07:00
afkarxyz 78caf6cc61 .simple open folder 2026-04-02 10:36:40 +07:00
afkarxyz 9314b8ec99 .fix audio analyzer alac 2026-04-02 10:31:56 +07:00
afkarxyz cfcb890469 .link resolver 2026-04-02 10:14:49 +07:00
afkarxyz e74ac07afc .rename 2026-04-02 09:34:41 +07:00
afkarxyz 0475529535 .spotfetch isrc 2026-04-02 09:33:12 +07:00
afkarxyz 264b474903 .refine check status 2026-04-02 08:55:24 +07:00
afkarxyz 6066278fe6 .icon 2026-04-02 08:46:34 +07:00
afkarxyz cf36d28444 .separate songlink 2026-04-02 08:36:42 +07:00
afkarxyz 7ce66b4732 .priority api 2026-04-02 08:29:37 +07:00
afkarxyz b96fc8d96c .isrc db 2026-04-02 08:23:58 +07:00
afkarxyz 6de2bae67b .isrc finder 2026-04-02 08:17:35 +07:00
afkarxyz 3e04868746 .update 2026-04-02 08:00:56 +07:00
afkarxyz e3f8f7be0a .cleanup 2026-03-25 20:53:26 +07:00
afkarxyz 5ebd28982b .final 2026-03-25 20:44:31 +07:00
afkarxyz f8ef1180f6 .improve audio quality analyzer 2026-03-25 20:10:05 +07:00
afkarxyz 386c541658 .reorder audio tools 2026-03-25 19:56:23 +07:00
afkarxyz d60a068cab .x 2026-03-25 19:47:40 +07:00
afkarxyz 78adc15be3 .tools refine hover 2026-03-25 19:34:54 +07:00
afkarxyz 724520f51f .refine progressbar audio quality analyzer 2026-03-25 19:25:26 +07:00
afkarxyz c342c3f9ee .remake audio quality analyzer 2026-03-25 18:52:27 +07:00
afkarxyz 8919b9a77a .unicode issue converter 2026-03-25 18:04:22 +07:00
afkarxyz 528bf65771 .refine audio quality analyzer 2026-03-25 17:39:43 +07:00
afkarxyz 0e6b6f9d39 .audio resampler 2026-03-25 17:24:38 +07:00
afkarxyz 45885e1856 .rename 2026-03-25 16:24:24 +07:00
afkarxyz b31e1fe565 .fix spotify rate limit issue 2026-03-25 16:17:44 +07:00
afkarxyz 4e7fc468cd .history page refined 2026-03-25 15:13:36 +07:00
afkarxyz d8722c58dc .refine artist fetch all tracks 2026-03-25 13:43:14 +07:00
afkarxyz dd67b54ea9 .refine issue 2026-03-25 12:06:54 +07:00
afkarxyz cbca6c799f .improve about page 2026-03-25 12:02:14 +07:00
35 changed files with 1255 additions and 2171 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 ## 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) · [musicdl.me](https://musicdl.me) [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)
> [!TIP] > [!TIP]
> >
+78 -255
View File
@@ -307,6 +307,7 @@ type DownloadRequest struct {
ReleaseDate string `json:"release_date,omitempty"` ReleaseDate string `json:"release_date,omitempty"`
CoverURL string `json:"cover_url,omitempty"` CoverURL string `json:"cover_url,omitempty"`
TidalAPIURL string `json:"tidal_api_url,omitempty"` TidalAPIURL string `json:"tidal_api_url,omitempty"`
TidalVariant string `json:"tidal_variant,omitempty"`
OutputDir string `json:"output_dir,omitempty"` OutputDir string `json:"output_dir,omitempty"`
AudioFormat string `json:"audio_format,omitempty"` AudioFormat string `json:"audio_format,omitempty"`
FilenameFormat string `json:"filename_format,omitempty"` FilenameFormat string `json:"filename_format,omitempty"`
@@ -507,8 +508,7 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
if req.FilenameFormat == "" { if req.FilenameFormat == "" {
req.FilenameFormat = "title-artist" req.FilenameFormat = "title-artist"
} }
shouldResolveISRC := strings.Contains(req.FilenameFormat, "{isrc}") || backend.GetExistingFileCheckModeSetting() == "isrc" if req.ISRC == "" && strings.Contains(req.FilenameFormat, "{isrc}") && req.SpotifyID != "" {
if req.ISRC == "" && shouldResolveISRC && req.SpotifyID != "" {
req.ISRC = backend.ResolveTrackISRC(req.SpotifyID) req.ISRC = backend.ResolveTrackISRC(req.SpotifyID)
} }
@@ -662,7 +662,11 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
} }
case "tidal": case "tidal":
if req.TidalAPIURL == "" || req.TidalAPIURL == "auto" { tidalVariant := strings.ToLower(strings.TrimSpace(req.TidalVariant))
if tidalVariant == "alt" {
downloader := backend.NewTidalDownloader("")
filename, err = downloader.DownloadAlt(req.SpotifyID, req.OutputDir, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, req.Composer, metadataSeparator, req.ISRC, spotifyURL, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre)
} else if req.TidalAPIURL == "" || req.TidalAPIURL == "auto" {
downloader := backend.NewTidalDownloader("") downloader := backend.NewTidalDownloader("")
if req.ServiceURL != "" { if req.ServiceURL != "" {
filename, err = downloader.DownloadByURLWithFallback(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, req.Composer, metadataSeparator, req.ISRC, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre) filename, err = downloader.DownloadByURLWithFallback(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, req.Composer, metadataSeparator, req.ISRC, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre)
@@ -791,6 +795,9 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
} }
historySource := req.Service historySource := req.Service
if req.Service == "tidal" && strings.EqualFold(strings.TrimSpace(req.TidalVariant), "alt") {
historySource = "tidal alt"
}
go func(fPath, track, artist, album, sID, cover, format, source string) { go func(fPath, track, artist, album, sID, cover, format, source string) {
time.Sleep(2 * time.Second) time.Sleep(2 * time.Second)
@@ -819,21 +826,21 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
DurationStr: durationStr, DurationStr: durationStr,
CoverURL: cover, CoverURL: cover,
Quality: quality, Quality: quality,
Format: strings.ToUpper(format),
Path: fPath, Path: fPath,
Source: source, Source: source,
} }
item.Format = strings.ToUpper(strings.TrimSpace(format)) if item.Format == "" || item.Format == "LOSSLESS" {
ext := filepath.Ext(fPath)
if ext := filepath.Ext(fPath); len(ext) > 1 { if len(ext) > 1 {
item.Format = strings.ToUpper(ext[1:]) item.Format = strings.ToUpper(ext[1:])
} }
}
switch item.Format { switch item.Format {
case "6", "7", "27", "LOSSLESS", "HI_RES", "HI_RES_LOSSLESS": case "6", "7", "27":
item.Format = "FLAC" item.Format = "FLAC"
case "ALAC", "APPLE", "ATMOS", "M4A-AAC", "M4A-ALAC":
item.Format = "M4A"
} }
backend.AddHistoryItem(item, "SpotiFLAC") backend.AddHistoryItem(item, "SpotiFLAC")
@@ -1022,90 +1029,6 @@ func (a *App) CheckAPIStatus(apiType string, apiURL string) bool {
return isOnline 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 { func buildTidalStatusCheckURLs(apiURL string) []string {
apiURL = strings.TrimRight(strings.TrimSpace(apiURL), "/") apiURL = strings.TrimRight(strings.TrimSpace(apiURL), "/")
if apiURL != "" { if apiURL != "" {
@@ -1135,18 +1058,18 @@ func buildQobuzStatusCheckURLs(apiURL string) []string {
} }
bases := backend.GetQobuzStreamAPIBaseURLs() bases := backend.GetQobuzStreamAPIBaseURLs()
urls := make([]string, 0, len(bases)+1) urls := make([]string, 0, len(bases))
for _, baseURL := range bases { for _, baseURL := range bases {
urls = append(urls, buildQobuzStatusCheckURL(baseURL)) urls = append(urls, buildQobuzStatusCheckURL(baseURL))
} }
if musicDLURL := strings.TrimSpace(backend.GetQobuzMusicDLDownloadAPIURL()); musicDLURL != "" {
urls = append(urls, musicDLURL)
}
return urls return urls
} }
func buildQobuzStatusCheckURL(apiBase string) string { func buildQobuzStatusCheckURL(apiBase string) string {
apiBase = strings.TrimSpace(apiBase) 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) return fmt.Sprintf("%s360735657&quality=27", apiBase)
} }
@@ -1215,10 +1138,6 @@ func checkGroupedAPIStatus(apiType string, checkURLs []string) bool {
func checkSingleAPIStatus(apiType string, checkURL string) bool { func checkSingleAPIStatus(apiType string, checkURL string) bool {
client := &http.Client{Timeout: 4 * time.Second} 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) req, err := backend.NewRequestWithDefaultHeaders(http.MethodGet, checkURL, nil)
if err != nil { if err != nil {
return false return false
@@ -1814,68 +1733,6 @@ type CheckFileExistenceResult struct {
ArtistName string `json:"artist_name,omitempty"` 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 { func (a *App) CheckFilesExistence(outputDir string, rootDir string, tracks []CheckFileExistenceRequest) []CheckFileExistenceResult {
if len(tracks) == 0 { if len(tracks) == 0 {
return []CheckFileExistenceResult{} return []CheckFileExistenceResult{}
@@ -1888,11 +1745,6 @@ func (a *App) CheckFilesExistence(outputDir string, rootDir string, tracks []Che
defaultFilenameFormat := "title-artist" defaultFilenameFormat := "title-artist"
redownloadWithSuffix := backend.GetRedownloadWithSuffixSetting() redownloadWithSuffix := backend.GetRedownloadWithSuffixSetting()
existingFileCheckMode := backend.GetExistingFileCheckModeSetting()
scanRoot := outputDir
if rootDir != "" {
scanRoot = rootDir
}
type result struct { type result struct {
index int index int
@@ -1900,13 +1752,29 @@ func (a *App) CheckFilesExistence(outputDir string, rootDir string, tracks []Che
} }
resultsChan := make(chan result, len(tracks)) resultsChan := make(chan result, len(tracks))
var lookupIndex existingFileLookupIndex
var lookupIndexOnce sync.Once var rootDirFiles map[string]string
getLookupIndex := func() existingFileLookupIndex { rootDirFilesOnce := false
lookupIndexOnce.Do(func() { getRootDirFiles := func() map[string]string {
lookupIndex = buildExistingFileLookupIndex(scanRoot, existingFileCheckMode) 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
}) })
return lookupIndex }
rootDirFilesOnce = true
return rootDirFiles
} }
for i, track := range tracks { for i, track := range tracks {
@@ -1928,8 +1796,7 @@ func (a *App) CheckFilesExistence(outputDir string, rootDir string, tracks []Che
filenameFormat = defaultFilenameFormat filenameFormat = defaultFilenameFormat
} }
isrc := strings.TrimSpace(t.ISRC) isrc := strings.TrimSpace(t.ISRC)
shouldResolveISRC := existingFileCheckMode == "isrc" || strings.Contains(filenameFormat, "{isrc}") if isrc == "" && strings.Contains(filenameFormat, "{isrc}") && t.SpotifyID != "" {
if isrc == "" && shouldResolveISRC && t.SpotifyID != "" {
isrc = backend.ResolveTrackISRC(t.SpotifyID) isrc = backend.ResolveTrackISRC(t.SpotifyID)
} }
@@ -1939,11 +1806,8 @@ func (a *App) CheckFilesExistence(outputDir string, rootDir string, tracks []Che
} }
fileExt := ".flac" fileExt := ".flac"
switch strings.ToLower(strings.TrimSpace(t.AudioFormat)) { if t.AudioFormat == "mp3" {
case "mp3":
fileExt = ".mp3" fileExt = ".mp3"
case "m4a", "m4a-aac", "m4a-alac", "alac", "atmos", "apple":
fileExt = ".m4a"
} }
expectedFilenameBase := backend.BuildExpectedFilename( expectedFilenameBase := backend.BuildExpectedFilename(
@@ -1972,29 +1836,14 @@ func (a *App) CheckFilesExistence(outputDir string, rootDir string, tracks []Che
expectedPath := filepath.Join(targetDir, expectedFilename) expectedPath := filepath.Join(targetDir, expectedFilename)
if redownloadWithSuffix { if redownloadWithSuffix {
expectedPath, _ = backend.ResolveOutputPathForDownload(expectedPath, true) expectedPath, _ = backend.ResolveOutputPathForDownload(expectedPath, true)
resultsChan <- result{index: idx, result: res} res.FilePath = filepath.Base(expectedPath)
return } else {
}
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 { if fileInfo, err := os.Stat(expectedPath); err == nil && fileInfo.Size() > 100*1024 {
res.Exists = true res.Exists = true
res.FilePath = expectedPath res.FilePath = expectedPath
} else if path, ok := getLookupIndex().byFilename[filepath.Base(expectedPath)]; ok { } else {
res.Exists = true
res.FilePath = path res.FilePath = expectedFilename
} }
} }
@@ -2003,10 +1852,39 @@ func (a *App) CheckFilesExistence(outputDir string, rootDir string, tracks []Che
} }
results := make([]CheckFileExistenceResult, len(tracks)) results := make([]CheckFileExistenceResult, len(tracks))
missingIndices := []int{}
for i := 0; i < len(tracks); i++ { for i := 0; i < len(tracks); i++ {
r := <-resultsChan r := <-resultsChan
results[r.index] = r.result 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 return results
@@ -2032,14 +1910,6 @@ func (a *App) GetConfigPath() (string, error) {
return filepath.Join(dir, "config.json"), nil 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 { func (a *App) SaveSettings(settings map[string]interface{}) error {
configPath, err := a.GetConfigPath() configPath, err := a.GetConfigPath()
if err != nil { if err != nil {
@@ -2061,27 +1931,6 @@ func (a *App) SaveSettings(settings map[string]interface{}) error {
return os.WriteFile(configPath, data, 0644) 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) { func (a *App) LoadSettings() (map[string]interface{}, error) {
configPath, err := a.GetConfigPath() configPath, err := a.GetConfigPath()
if err != nil { if err != nil {
@@ -2105,32 +1954,6 @@ func (a *App) LoadSettings() (map[string]interface{}, error) {
return settings, nil 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) { func (a *App) CheckFFmpegInstalled() (bool, error) {
return backend.IsFFmpegInstalled() return backend.IsFFmpegInstalled()
} }
-80
View File
@@ -1,9 +1,6 @@
package backend package backend
import ( import (
"crypto/aes"
"crypto/cipher"
"crypto/sha256"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
@@ -13,7 +10,6 @@ import (
"path/filepath" "path/filepath"
"regexp" "regexp"
"strings" "strings"
"sync"
"time" "time"
) )
@@ -27,76 +23,6 @@ type AmazonStreamResponse struct {
DecryptionKey string `json:"decryptionKey"` 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 { func NewAmazonDownloader() *AmazonDownloader {
return &AmazonDownloader{ return &AmazonDownloader{
client: &http.Client{ client: &http.Client{
@@ -136,12 +62,6 @@ func (a *AmazonDownloader) DownloadFromAfkarXYZ(amazonURL, outputDir, quality st
return "", err 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) fmt.Printf("Fetching from Amazon API (ASIN: %s)...\n", asin)
resp, err := a.client.Do(req) resp, err := a.client.Do(req)
if err != nil { if err != nil {
-34
View File
@@ -60,40 +60,6 @@ func GetRedownloadWithSuffixSetting() bool {
return enabled 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 { func GetLinkResolverSetting() string {
settings, err := LoadConfigSettings() settings, err := LoadConfigSettings()
if err != nil || settings == nil { if err != nil || settings == nil {
+49 -186
View File
@@ -19,11 +19,6 @@ import (
"golang.org/x/text/unicode/norm" "golang.org/x/text/unicode/norm"
) )
type executableCandidate struct {
path string
source string
}
func ValidateExecutable(path string) error { func ValidateExecutable(path string) error {
cleanedPath := filepath.Clean(path) cleanedPath := filepath.Clean(path)
if cleanedPath == "" { if cleanedPath == "" {
@@ -88,50 +83,6 @@ func GetFFmpegDir() (string, error) {
return EnsureAppDir() 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 { func resolveSystemExecutable(executableName string) string {
if runtime.GOOS == "darwin" { if runtime.GOOS == "darwin" {
candidates := []string{ candidates := []string{
@@ -163,163 +114,83 @@ func resolveSystemExecutable(executableName string) string {
return "" return ""
} }
func runExecutableVersionCheck(path string) error { func GetFFmpegPath() (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() ffmpegDir, err := GetFFmpegDir()
if err != nil { if err != nil {
return "", "", err 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" ffmpegName := "ffmpeg"
if runtime.GOOS == "windows" { if runtime.GOOS == "windows" {
ffmpegName = "ffmpeg.exe" ffmpegName = "ffmpeg.exe"
} }
path, localPath, err := resolveExecutablePath(ffmpegName) if path := resolveSystemExecutable(ffmpegName); path != "" {
if err != nil { return path, nil
if localPath != "" {
return localPath, err
}
return "", err
} }
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) { func GetFFprobePath() (string, error) {
ffmpegDir, err := GetFFmpegDir()
if err != nil {
return "", err
}
ffprobeName := "ffprobe" ffprobeName := "ffprobe"
if runtime.GOOS == "windows" { if runtime.GOOS == "windows" {
ffprobeName = "ffprobe.exe" ffprobeName = "ffprobe.exe"
} }
path, localPath, err := resolveExecutablePath(ffprobeName) if path := resolveSystemExecutable(ffprobeName); path != "" {
if err != nil { return path, nil
if localPath != "" {
return localPath, err
}
return "", err
} }
return path, nil 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")
} }
func IsFFprobeInstalled() (bool, error) { func IsFFprobeInstalled() (bool, error) {
_, err := GetFFprobePath() 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()
return err == nil, nil return err == nil, nil
} }
func IsFFmpegInstalled() (bool, error) { func IsFFmpegInstalled() (bool, error) {
if _, err := GetFFmpegPath(); err != nil { 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 {
return false, nil return false, nil
} }
@@ -636,10 +507,6 @@ func extractZip(zipPath, destDir string) error {
return fmt.Errorf("failed to extract file: %w", err) 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) fmt.Printf("[FFmpeg] Extracted to: %s\n", destPath)
} }
@@ -717,10 +584,6 @@ func extractTarXz(tarXzPath, destDir string) error {
return fmt.Errorf("failed to extract file: %w", err) 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) fmt.Printf("[FFmpeg] Extracted to: %s\n", destPath)
} }
+1 -5
View File
@@ -1,21 +1,17 @@
package backend package backend
const amazonMusicAPIBaseURL = "https://amazon.spotbye.qzz.io" const amazonMusicAPIBaseURL = "https://amazon.spotbye.qzz.io"
const qobuzMusicDLDownloadAPIURL = "https://www.musicdl.me/api/qobuz/download"
var defaultQobuzStreamAPIBaseURLs = []string{ var defaultQobuzStreamAPIBaseURLs = []string{
"https://dab.yeet.su/api/stream?trackId=", "https://dab.yeet.su/api/stream?trackId=",
"https://dabmusic.xyz/api/stream?trackId=", "https://dabmusic.xyz/api/stream?trackId=",
"https://qobuz.spotbye.qzz.io/api/track/",
} }
func GetQobuzStreamAPIBaseURLs() []string { func GetQobuzStreamAPIBaseURLs() []string {
return append([]string(nil), defaultQobuzStreamAPIBaseURLs...) return append([]string(nil), defaultQobuzStreamAPIBaseURLs...)
} }
func GetQobuzMusicDLDownloadAPIURL() string {
return qobuzMusicDLDownloadAPIURL
}
func GetAmazonMusicAPIBaseURL() string { func GetAmazonMusicAPIBaseURL() string {
return amazonMusicAPIBaseURL return amazonMusicAPIBaseURL
} }
+10 -214
View File
@@ -1,10 +1,6 @@
package backend package backend
import ( import (
"bytes"
"crypto/aes"
"crypto/cipher"
"crypto/sha256"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
@@ -14,7 +10,6 @@ import (
"path/filepath" "path/filepath"
"regexp" "regexp"
"strings" "strings"
"sync"
"time" "time"
) )
@@ -73,57 +68,6 @@ type QobuzStreamResponse struct {
URL string `json:"url"` 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 { func NewQobuzDownloader() *QobuzDownloader {
return &QobuzDownloader{ return &QobuzDownloader{
client: &http.Client{ client: &http.Client{
@@ -133,57 +77,6 @@ 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) { func (q *QobuzDownloader) searchByISRC(isrc string) (*QobuzTrack, error) {
if strings.HasPrefix(isrc, "qobuz_") { if strings.HasPrefix(isrc, "qobuz_") {
trackID := strings.TrimPrefix(isrc, "qobuz_") trackID := strings.TrimPrefix(isrc, "qobuz_")
@@ -246,6 +139,9 @@ func (q *QobuzDownloader) searchByISRC(isrc string) (*QobuzTrack, error) {
} }
func buildQobuzAPIURL(apiBase string, trackID int64, quality string) string { func buildQobuzAPIURL(apiBase string, trackID int64, quality string) string {
if strings.Contains(apiBase, "qobuz.spotbye.qzz.io") {
return fmt.Sprintf("%s%d?quality=%s", apiBase, trackID, quality)
}
return fmt.Sprintf("%s%d&quality=%s", apiBase, trackID, quality) return fmt.Sprintf("%s%d&quality=%s", apiBase, trackID, quality)
} }
@@ -292,81 +188,6 @@ func (q *QobuzDownloader) DownloadFromStandard(apiBase string, trackID int64, qu
return "", fmt.Errorf("invalid response") 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) { func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string, allowFallback bool) (string, error) {
qualityCode := quality qualityCode := quality
if qualityCode == "" || qualityCode == "5" { if qualityCode == "" || qualityCode == "5" {
@@ -375,6 +196,8 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string, allowFal
fmt.Printf("Getting download URL for track ID: %d with requested quality: %s\n", trackID, qualityCode) fmt.Printf("Getting download URL for track ID: %d with requested quality: %s\n", trackID, qualityCode)
standardAPIs := prioritizeProviders("qobuz", GetQobuzStreamAPIBaseURLs())
downloadFunc := func(qual string) (string, error) { downloadFunc := func(qual string) (string, error) {
type Provider struct { type Provider struct {
Name string Name string
@@ -382,48 +205,21 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string, allowFal
Func func() (string, error) Func func() (string, error)
} }
providerMap := make(map[string]Provider) var providers []Provider
providerIDs := []string{GetQobuzMusicDLDownloadAPIURL()}
providerMap[GetQobuzMusicDLDownloadAPIURL()] = Provider{ for _, api := range standardAPIs {
Name: "MusicDL",
API: GetQobuzMusicDLDownloadAPIURL(),
Func: func() (string, error) {
return q.DownloadFromMusicDL(trackID, qual)
},
}
for _, api := range GetQobuzStreamAPIBaseURLs() {
currentAPI := api currentAPI := api
providerIDs = append(providerIDs, currentAPI) providers = append(providers, Provider{
providerMap[currentAPI] = Provider{
Name: "Standard(" + currentAPI + ")", Name: "Standard(" + currentAPI + ")",
API: currentAPI, API: currentAPI,
Func: func() (string, error) { Func: func() (string, error) {
return q.DownloadFromStandard(currentAPI, trackID, qual) 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 var lastErr error
for _, providerID := range orderedProviderIDs { for _, p := range providers {
p, ok := providerMap[providerID]
if !ok {
continue
}
fmt.Printf("Trying Provider: %s (Quality: %s)...\n", p.Name, qual) fmt.Printf("Trying Provider: %s (Quality: %s)...\n", p.Name, qual)
url, err := p.Func() url, err := p.Func()
+10 -189
View File
@@ -9,7 +9,6 @@ import (
"net/http" "net/http"
"os" "os"
"os/exec" "os/exec"
"path/filepath"
"regexp" "regexp"
"strings" "strings"
"time" "time"
@@ -48,172 +47,10 @@ type TidalBTSManifest struct {
URLs []string `json:"urls"` 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 { func NewTidalDownloader(apiURL string) *TidalDownloader {
apiURL = strings.TrimRight(strings.TrimSpace(apiURL), "/") apiURL = strings.TrimRight(strings.TrimSpace(apiURL), "/")
if apiURL == "" { if apiURL == "" {
apis, err := getConfiguredTidalAPIAttemptList() apis, err := GetRotatedTidalAPIList()
if err == nil && len(apis) > 0 { if err == nil && len(apis) > 0 {
apiURL = apis[0] apiURL = apis[0]
} }
@@ -230,7 +67,7 @@ func NewTidalDownloader(apiURL string) *TidalDownloader {
} }
func (t *TidalDownloader) GetAvailableAPIs() ([]string, error) { func (t *TidalDownloader) GetAvailableAPIs() ([]string, error) {
apis, err := getConfiguredTidalAPIAttemptList() apis, err := GetRotatedTidalAPIList()
if err == nil && len(apis) > 0 { if err == nil && len(apis) > 0 {
return apis, nil return apis, nil
} }
@@ -336,10 +173,10 @@ func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (string,
return "", fmt.Errorf("download URL not found in response") return "", fmt.Errorf("download URL not found in response")
} }
func (t *TidalDownloader) DownloadFile(url, filepath string, quality string) error { func (t *TidalDownloader) DownloadFile(url, filepath string) error {
if strings.HasPrefix(url, "MANIFEST:") { if strings.HasPrefix(url, "MANIFEST:") {
return t.DownloadFromManifest(strings.TrimPrefix(url, "MANIFEST:"), filepath, quality) return t.DownloadFromManifest(strings.TrimPrefix(url, "MANIFEST:"), filepath)
} }
req, err := NewRequestWithDefaultHeaders(http.MethodGet, url, nil) req, err := NewRequestWithDefaultHeaders(http.MethodGet, url, nil)
@@ -376,18 +213,12 @@ func (t *TidalDownloader) DownloadFile(url, filepath string, quality string) err
return nil return nil
} }
func (t *TidalDownloader) DownloadFromManifest(manifestB64, outputPath string, quality string) error { func (t *TidalDownloader) DownloadFromManifest(manifestB64, outputPath string) error {
directURL, initURL, mediaURLs, mimeType, err := parseManifest(manifestB64) directURL, initURL, mediaURLs, mimeType, err := parseManifest(manifestB64)
if err != nil { if err != nil {
return fmt.Errorf("failed to parse manifest: %w", err) 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{ client := &http.Client{
Timeout: 120 * time.Second, Timeout: 120 * time.Second,
} }
@@ -602,7 +433,7 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo
} }
fmt.Printf("Downloading to: %s\n", outputFilename) fmt.Printf("Downloading to: %s\n", outputFilename)
if err := t.DownloadFile(downloadURL, outputFilename, quality); err != nil { if err := t.DownloadFile(downloadURL, outputFilename); err != nil {
cleanupTidalDownloadArtifacts(outputFilename) cleanupTidalDownloadArtifacts(outputFilename)
return outputFilename, err return outputFilename, err
} }
@@ -662,10 +493,6 @@ func (t *TidalDownloader) Download(spotifyTrackID, outputDir, quality, filenameF
return "", fmt.Errorf("songlink/songstats couldn't find Tidal URL: %w", err) 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) 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)
} }
@@ -723,12 +550,10 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
var mpd MPD var mpd MPD
var segTemplate *SegmentTemplate var segTemplate *SegmentTemplate
var dashMimeType string
if err := xml.Unmarshal(manifestBytes, &mpd); err == nil { if err := xml.Unmarshal(manifestBytes, &mpd); err == nil {
var selectedBandwidth int var selectedBandwidth int
var selectedCodecs string var selectedCodecs string
var selectedMimeType string
for _, as := range mpd.Period.AdaptationSets { for _, as := range mpd.Period.AdaptationSets {
@@ -737,7 +562,6 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
if segTemplate == nil { if segTemplate == nil {
segTemplate = as.SegmentTemplate segTemplate = as.SegmentTemplate
selectedCodecs = as.Codecs selectedCodecs = as.Codecs
selectedMimeType = as.MimeType
} }
} }
@@ -752,8 +576,6 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
} else { } else {
selectedCodecs = as.Codecs selectedCodecs = as.Codecs
} }
selectedMimeType = as.MimeType
} }
} }
} }
@@ -761,7 +583,6 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
if selectedBandwidth > 0 { if selectedBandwidth > 0 {
fmt.Printf("Selected stream: Codec=%s, Bandwidth=%d bps\n", selectedCodecs, selectedBandwidth) fmt.Printf("Selected stream: Codec=%s, Bandwidth=%d bps\n", selectedCodecs, selectedBandwidth)
dashMimeType = fmt.Sprintf("%s; codecs=\"%s\"", selectedMimeType, selectedCodecs)
} }
} }
@@ -787,7 +608,7 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
mediaURL := strings.ReplaceAll(mediaTemplate, "$Number$", fmt.Sprintf("%d", i)) mediaURL := strings.ReplaceAll(mediaTemplate, "$Number$", fmt.Sprintf("%d", i))
mediaURLs = append(mediaURLs, mediaURL) mediaURLs = append(mediaURLs, mediaURL)
} }
return "", initURL, mediaURLs, dashMimeType, nil return "", initURL, mediaURLs, "", nil
} }
fmt.Println("Using regex fallback for DASH manifest...") fmt.Println("Using regex fallback for DASH manifest...")
@@ -834,7 +655,7 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
mediaURLs = append(mediaURLs, mediaURL) mediaURLs = append(mediaURLs, mediaURL)
} }
return "", initURL, mediaURLs, dashMimeType, nil return "", initURL, mediaURLs, "", nil
} }
func (t *TidalDownloader) downloadWithRotatingAPIs(trackID int64, outputFilename string, quality string, allowFallback bool) (string, error) { func (t *TidalDownloader) downloadWithRotatingAPIs(trackID int64, outputFilename string, quality string, allowFallback bool) (string, error) {
@@ -863,7 +684,7 @@ func (t *TidalDownloader) downloadWithRotatingAPIs(trackID int64, outputFilename
} }
func (t *TidalDownloader) tryDownloadAcrossTidalAPIs(trackID int64, outputFilename string, quality string, refreshed bool) (string, error) { func (t *TidalDownloader) tryDownloadAcrossTidalAPIs(trackID int64, outputFilename string, quality string, refreshed bool) (string, error) {
apis, err := getConfiguredTidalAPIAttemptList() apis, err := GetRotatedTidalAPIList()
if err != nil && len(apis) == 0 { if err != nil && len(apis) == 0 {
return "", fmt.Errorf("failed to load tidal api list: %w", err) return "", fmt.Errorf("failed to load tidal api list: %w", err)
} }
@@ -885,7 +706,7 @@ func (t *TidalDownloader) tryDownloadAcrossTidalAPIs(trackID int64, outputFilena
continue continue
} }
if err := downloader.DownloadFile(downloadURL, outputFilename, quality); err != nil { if err := downloader.DownloadFile(downloadURL, outputFilename); err != nil {
lastErr = err lastErr = err
cleanupTidalDownloadArtifacts(outputFilename) cleanupTidalDownloadArtifacts(outputFilename)
errors = append(errors, fmt.Sprintf("%s: %v", apiURL, err)) errors = append(errors, fmt.Sprintf("%s: %v", apiURL, err))
+238
View File
@@ -0,0 +1,238 @@
package backend
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
)
const tidalAltDownloadAPIBaseURL = "https://tidal.spotbye.qzz.io/get"
type TidalAltAPIResponse struct {
Title string `json:"title"`
Link string `json:"link"`
}
func buildTidalOutputPath(outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyTrackNumber, spotifyDiscNumber int, isrcOverride string, useFirstArtistOnly bool) (string, bool, error) {
if outputDir != "." {
if err := os.MkdirAll(outputDir, 0755); err != nil {
return "", false, fmt.Errorf("directory error: %w", err)
}
}
artistNameForFile := sanitizeFilename(spotifyArtistName)
albumArtistForFile := sanitizeFilename(spotifyAlbumArtist)
if useFirstArtistOnly {
artistNameForFile = sanitizeFilename(GetFirstArtist(spotifyArtistName))
albumArtistForFile = sanitizeFilename(GetFirstArtist(spotifyAlbumArtist))
}
trackTitleForFile := sanitizeFilename(spotifyTrackName)
albumTitleForFile := sanitizeFilename(spotifyAlbumName)
filename := buildTidalFilename(trackTitleForFile, artistNameForFile, albumTitleForFile, albumArtistForFile, spotifyReleaseDate, spotifyTrackNumber, spotifyDiscNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber, isrcOverride)
outputFilename := filepath.Join(outputDir, filename)
outputFilename, alreadyExists := ResolveOutputPathForDownload(outputFilename, GetRedownloadWithSuffixSetting())
return outputFilename, alreadyExists, nil
}
func finalizeTidalDownload(outputFilename, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL string, useSingleGenre bool, embedGenre bool) {
trackTitle := spotifyTrackName
artistName := spotifyArtistName
albumTitle := spotifyAlbumName
type mbResult struct {
ISRC string
Metadata Metadata
}
metaChan := make(chan mbResult, 1)
if embedGenre && spotifyURL != "" {
go func() {
res := mbResult{}
var isrc string
parts := strings.Split(spotifyURL, "/")
if len(parts) > 0 {
sID := strings.Split(parts[len(parts)-1], "?")[0]
if sID != "" {
client := NewSongLinkClient()
if val, err := client.GetISRC(sID); err == nil {
isrc = val
}
}
}
res.ISRC = isrc
if isrc != "" {
if ShouldSkipMusicBrainzMetadataFetch() {
fmt.Println("Skipping MusicBrainz metadata fetch because status check is offline.")
} else {
fmt.Println("Fetching MusicBrainz metadata...")
if fetchedMeta, err := FetchMusicBrainzMetadata(isrc, trackTitle, artistName, albumTitle, useSingleGenre, embedGenre); err == nil {
res.Metadata = fetchedMeta
fmt.Println("✓ MusicBrainz metadata fetched")
} else {
fmt.Printf("Warning: Failed to fetch MusicBrainz metadata: %v\n", err)
}
}
}
metaChan <- res
}()
} else {
close(metaChan)
}
isrc := strings.TrimSpace(isrcOverride)
var mbMeta Metadata
if spotifyURL != "" {
result := <-metaChan
if isrc == "" {
isrc = result.ISRC
}
mbMeta = result.Metadata
}
upc := ""
if spotifyURL != "" {
if identifiers, err := GetSpotifyTrackIdentifiersDirect(spotifyURL); err == nil || identifiers.ISRC != "" || identifiers.UPC != "" {
if strings.TrimSpace(isrc) == "" && strings.TrimSpace(identifiers.ISRC) != "" {
isrc = strings.TrimSpace(identifiers.ISRC)
}
upc = strings.TrimSpace(identifiers.UPC)
}
}
fmt.Println("Adding metadata...")
coverPath := ""
if spotifyCoverURL != "" {
coverPath = outputFilename + ".cover.jpg"
coverClient := NewCoverClient()
if err := coverClient.DownloadCoverToPath(spotifyCoverURL, coverPath, embedMaxQualityCover); err != nil {
fmt.Printf("Warning: Failed to download Spotify cover: %v\n", err)
coverPath = ""
} else {
defer os.Remove(coverPath)
fmt.Println("Spotify cover downloaded")
}
}
trackNumberToEmbed := spotifyTrackNumber
if trackNumberToEmbed == 0 {
trackNumberToEmbed = 1
}
metadata := Metadata{
Title: trackTitle,
Artist: artistName,
Album: albumTitle,
AlbumArtist: spotifyAlbumArtist,
Date: spotifyReleaseDate,
TrackNumber: trackNumberToEmbed,
TotalTracks: spotifyTotalTracks,
DiscNumber: spotifyDiscNumber,
TotalDiscs: spotifyTotalDiscs,
URL: spotifyURL,
Comment: spotifyURL,
Copyright: spotifyCopyright,
Publisher: spotifyPublisher,
Composer: spotifyComposer,
Separator: metadataSeparator,
Description: "https://github.com/spotbye/SpotiFLAC",
ISRC: isrc,
UPC: upc,
Genre: mbMeta.Genre,
}
if err := EmbedMetadata(outputFilename, metadata, coverPath); err != nil {
fmt.Printf("Tagging failed: %v\n", err)
} else {
fmt.Println("Metadata saved")
}
}
func (t *TidalDownloader) GetAltDownloadURLFromSpotify(spotifyTrackID string) (string, error) {
spotifyTrackID = strings.TrimSpace(spotifyTrackID)
if spotifyTrackID == "" {
return "", fmt.Errorf("spotify track ID is required")
}
apiURL := fmt.Sprintf("%s/%s", tidalAltDownloadAPIBaseURL, spotifyTrackID)
fmt.Printf("Tidal Alt. API URL: %s\n", apiURL)
req, err := NewRequestWithDefaultHeaders(http.MethodGet, apiURL, nil)
if err != nil {
return "", fmt.Errorf("failed to create Tidal Alt. request: %w", err)
}
resp, err := t.client.Do(req)
if err != nil {
return "", fmt.Errorf("failed to get Tidal Alt. download URL: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read Tidal Alt. response: %w", err)
}
if resp.StatusCode != http.StatusOK {
preview := strings.TrimSpace(string(body))
if len(preview) > 200 {
preview = preview[:200] + "..."
}
return "", fmt.Errorf("Tidal Alt. returned status %d: %s", resp.StatusCode, preview)
}
var payload TidalAltAPIResponse
if err := json.Unmarshal(body, &payload); err != nil {
return "", fmt.Errorf("failed to decode Tidal Alt. response: %w", err)
}
downloadURL := strings.TrimSpace(payload.Link)
if downloadURL == "" {
return "", fmt.Errorf("Tidal Alt. response did not include a download link")
}
fmt.Println("✓ Tidal Alt. download URL found")
return downloadURL, nil
}
func (t *TidalDownloader) DownloadAlt(spotifyTrackID, outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL string, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) {
spotifyTrackID = strings.TrimSpace(spotifyTrackID)
if spotifyTrackID == "" {
return "", fmt.Errorf("spotify track ID is required for Tidal Alt.")
}
outputFilename, alreadyExists, err := buildTidalOutputPath(outputDir, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyTrackNumber, spotifyDiscNumber, isrcOverride, useFirstArtistOnly)
if err != nil {
return "", err
}
if alreadyExists {
fmt.Printf("File already exists: %s (%.2f MB)\n", outputFilename, float64(mustFileSize(outputFilename))/(1024*1024))
return "EXISTS:" + outputFilename, nil
}
fmt.Printf("Using Tidal Alt. for Spotify track: %s\n", spotifyTrackID)
downloadURL, err := t.GetAltDownloadURLFromSpotify(spotifyTrackID)
if err != nil {
return outputFilename, err
}
fmt.Printf("Downloading to: %s\n", outputFilename)
if err := t.DownloadFile(downloadURL, outputFilename); err != nil {
cleanupTidalDownloadArtifacts(outputFilename)
return outputFilename, err
}
finalizeTidalDownload(outputFilename, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL, useSingleGenre, embedGenre)
fmt.Println("Done")
fmt.Println("✓ Downloaded successfully from Tidal Alt.")
return outputFilename, nil
}
-1
View File
@@ -20,7 +20,6 @@
"@radix-ui/react-progress": "^1.1.8", "@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6", "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
+1 -1
View File
@@ -1 +1 @@
8864b4f7b7971b624d1ba25030f2db4e 867c45db7982e126a7249d80210f23be
-3
View File
@@ -32,9 +32,6 @@ importers:
'@radix-ui/react-select': '@radix-ui/react-select':
specifier: ^2.2.6 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) 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': '@radix-ui/react-slot':
specifier: ^1.2.4 specifier: ^1.2.4
version: 1.2.4(@types/react@19.2.14)(react@19.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) { if (savedSettings) {
applyThemeMode(savedSettings.themeMode); applyThemeMode(savedSettings.themeMode);
applyTheme(savedSettings.theme); applyTheme(savedSettings.theme);
applyFont(savedSettings.fontFamily, savedSettings.customFonts); applyFont(savedSettings.fontFamily);
} }
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -170,7 +170,7 @@ function App() {
const settings = await loadSettings(); const settings = await loadSettings();
applyThemeMode(settings.themeMode); applyThemeMode(settings.themeMode);
applyTheme(settings.theme); applyTheme(settings.theme);
applyFont(settings.fontFamily, settings.customFonts); applyFont(settings.fontFamily);
if (!settings.downloadPath) { if (!settings.downloadPath) {
const settingsWithDefaults = await getSettingsWithDefaults(); const settingsWithDefaults = await getSettingsWithDefaults();
await saveSettings(settingsWithDefaults); await saveSettings(settingsWithDefaults);
@@ -446,7 +446,7 @@ function App() {
} }
if ("album_info" in metadata.metadata) { if ("album_info" in metadata.metadata) {
const { album_info, track_list } = metadata.metadata; const { album_info, track_list } = metadata.metadata;
return (<AlbumInfo albumInfo={album_info} trackList={track_list} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={download.downloadedTracks} failedTracks={download.failedTracks} skippedTracks={download.skippedTracks} downloadingTrack={download.downloadingTrack} isDownloading={download.isDownloading} bulkDownloadType={download.bulkDownloadType} downloadProgress={download.downloadProgress} 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) => { 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) => {
const pendingArtistUrl = artist.external_urls.replace(/\/$/, "") + "/discography/all"; const pendingArtistUrl = artist.external_urls.replace(/\/$/, "") + "/discography/all";
setSpotifyUrl(pendingArtistUrl); setSpotifyUrl(pendingArtistUrl);
const artistUrl = await metadata.handleArtistClick(artist); const artistUrl = await metadata.handleArtistClick(artist);
@@ -464,7 +464,7 @@ function App() {
const { playlist_info, track_list } = metadata.metadata; const { playlist_info, track_list } = metadata.metadata;
const settings = getSettings(); const settings = getSettings();
const playlistFolderName = buildPlaylistFolderName(playlist_info.owner.name, playlist_info.owner.display_name, settings.playlistOwnerFolderName); 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} 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) => { 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) => {
const pendingArtistUrl = artist.external_urls.replace(/\/$/, "") + "/discography/all"; const pendingArtistUrl = artist.external_urls.replace(/\/$/, "") + "/discography/all";
setSpotifyUrl(pendingArtistUrl); setSpotifyUrl(pendingArtistUrl);
const artistUrl = await metadata.handleArtistClick(artist); const artistUrl = await metadata.handleArtistClick(artist);
@@ -480,7 +480,7 @@ function App() {
} }
if ("artist_info" in metadata.metadata) { if ("artist_info" in metadata.metadata) {
const { artist_info, album_list, track_list } = metadata.metadata; const { artist_info, album_list, track_list } = metadata.metadata;
return (<ArtistInfo artistInfo={artist_info} albumList={album_list} trackList={track_list} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={download.downloadedTracks} failedTracks={download.failedTracks} skippedTracks={download.skippedTracks} downloadingTrack={download.downloadingTrack} isDownloading={download.isDownloading} bulkDownloadType={download.bulkDownloadType} downloadProgress={download.downloadProgress} 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) => { 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) => {
const pendingArtistUrl = artist.external_urls.replace(/\/$/, "") + "/discography/all"; const pendingArtistUrl = artist.external_urls.replace(/\/$/, "") + "/discography/all";
setSpotifyUrl(pendingArtistUrl); setSpotifyUrl(pendingArtistUrl);
const artistUrl = await metadata.handleArtistClick(artist); const artistUrl = await metadata.handleArtistClick(artist);
@@ -512,7 +512,7 @@ function App() {
const savedSettings = getSettings(); const savedSettings = getSettings();
applyThemeMode(savedSettings.themeMode); applyThemeMode(savedSettings.themeMode);
applyTheme(savedSettings.theme); applyTheme(savedSettings.theme);
applyFont(savedSettings.fontFamily, savedSettings.customFonts); applyFont(savedSettings.fontFamily);
if (pendingPageChange) { if (pendingPageChange) {
setCurrentPage(pendingPageChange); setCurrentPage(pendingPageChange);
setPendingPageChange(null); setPendingPageChange(null);
@@ -551,7 +551,7 @@ function App() {
<Dialog open={metadata.showAlbumDialog} onOpenChange={metadata.setShowAlbumDialog}> <Dialog open={metadata.showAlbumDialog} onOpenChange={metadata.setShowAlbumDialog}>
<DialogContent className="sm:max-w-106.25 p-6 [&>button]:hidden"> <DialogContent className="sm:max-w-[425px] p-6 [&>button]:hidden">
<div className="absolute right-4 top-4"> <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)}> <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"/> <X className="h-4 w-4"/>
@@ -624,7 +624,7 @@ function App() {
<Dialog open={showUnsavedChangesDialog} onOpenChange={setShowUnsavedChangesDialog}> <Dialog open={showUnsavedChangesDialog} onOpenChange={setShowUnsavedChangesDialog}>
<DialogContent className="sm:max-w-106.25 [&>button]:hidden"> <DialogContent className="sm:max-w-[425px] [&>button]:hidden">
<DialogHeader> <DialogHeader>
<DialogTitle>Unsaved Changes</DialogTitle> <DialogTitle>Unsaved Changes</DialogTitle>
<DialogDescription> <DialogDescription>
@@ -671,7 +671,7 @@ function App() {
</Dialog> </Dialog>
<Dialog open={isFFmpegInstalled === false} onOpenChange={() => { }}> <Dialog open={isFFmpegInstalled === false} onOpenChange={() => { }}>
<DialogContent className="max-w-112.5 [&>button]:hidden p-6 gap-5"> <DialogContent className="max-w-[450px] [&>button]:hidden p-6 gap-5">
<DialogHeader className="space-y-2"> <DialogHeader className="space-y-2">
<DialogTitle className="text-lg font-bold tracking-tight"> <DialogTitle className="text-lg font-bold tracking-tight">
FFmpeg Required FFmpeg Required
+2 -2
View File
@@ -249,7 +249,7 @@ export function AboutPage() {
Note Note
</div> </div>
<p className="text-xs leading-snug text-sky-700 dark:text-sky-300"> <p className="text-xs leading-snug text-sky-700 dark:text-sky-300">
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. This project was created as a thank-you to everyone who has supported SpotiFLAC on Ko-fi.
</p> </p>
</div> </div>
</CardContent>)} </CardContent>)}
@@ -318,7 +318,7 @@ export function AboutPage() {
<CardTitle className="leading-tight">Browser Extensions & Scripts</CardTitle> <CardTitle className="leading-tight">Browser Extensions & Scripts</CardTitle>
<CardDescription className="flex flex-col gap-2.5 pt-1.5"> <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"> {browserExtensionItems.map((item) => (<div key={item.alt} className="flex items-center gap-2.5">
<img src={item.icon} className="h-5.5 w-5.5 rounded-sm shadow-sm" alt={item.alt}/> <img src={item.icon} className="h-[22px] w-[22px] rounded-sm shadow-sm" alt={item.alt}/>
<span className={`${projectBodyClass} text-muted-foreground`}> <span className={`${projectBodyClass} text-muted-foreground`}>
{item.label} {item.label}
</span> </span>
+2 -3
View File
@@ -35,7 +35,6 @@ interface AlbumInfoProps {
isDownloading: boolean; isDownloading: boolean;
bulkDownloadType: "all" | "selected" | null; bulkDownloadType: "all" | "selected" | null;
downloadProgress: number; downloadProgress: number;
downloadRemainingCount: number;
currentDownloadInfo: { currentDownloadInfo: {
name: string; name: string;
artists: string; artists: string;
@@ -78,7 +77,7 @@ interface AlbumInfoProps {
onTrackClick?: (track: TrackMetadata) => void; onTrackClick?: (track: TrackMetadata) => void;
onBack?: () => void; onBack?: () => void;
} }
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) { 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) {
const settings = getSettings(); const settings = getSettings();
const albumArtistNames = splitArtistNames(albumInfo.artists); const albumArtistNames = splitArtistNames(albumInfo.artists);
const artistSeparator = albumInfo.artists.includes(";") ? "; " : ", "; const artistSeparator = albumInfo.artists.includes(";") ? "; " : ", ";
@@ -271,7 +270,7 @@ export function AlbumInfo({ albumInfo, trackList, searchQuery, sortBy, selectedT
</TooltipContent> </TooltipContent>
</Tooltip>)} </Tooltip>)}
</div> </div>
{isDownloading && (<DownloadProgress progress={downloadProgress} remainingCount={downloadRemainingCount} currentTrack={currentDownloadInfo} onStop={onStopDownload}/>)} {isDownloading && (<DownloadProgress progress={downloadProgress} currentTrack={currentDownloadInfo} onStop={onStopDownload}/>)}
</div> </div>
</div> </div>
</CardContent> </CardContent>
+4 -5
View File
@@ -48,7 +48,6 @@ interface ArtistInfoProps {
isDownloading: boolean; isDownloading: boolean;
bulkDownloadType: "all" | "selected" | null; bulkDownloadType: "all" | "selected" | null;
downloadProgress: number; downloadProgress: number;
downloadRemainingCount: number;
currentDownloadInfo: { currentDownloadInfo: {
name: string; name: string;
artists: string; artists: string;
@@ -96,7 +95,7 @@ interface ArtistInfoProps {
onTrackClick?: (track: TrackMetadata) => void; onTrackClick?: (track: TrackMetadata) => void;
onBack?: () => void; onBack?: () => void;
} }
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) { 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) {
const [downloadingHeader, setDownloadingHeader] = useState(false); const [downloadingHeader, setDownloadingHeader] = useState(false);
const [downloadingAvatar, setDownloadingAvatar] = useState(false); const [downloadingAvatar, setDownloadingAvatar] = useState(false);
const [downloadingGalleryIndex, setDownloadingGalleryIndex] = useState<number | null>(null); const [downloadingGalleryIndex, setDownloadingGalleryIndex] = useState<number | null>(null);
@@ -326,7 +325,7 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
{artistInfo.header ? (<> {artistInfo.header ? (<>
<div className="relative w-full h-64 bg-cover bg-center"> <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-cover bg-center" style={{ backgroundImage: `url(${artistInfo.header})` }}/>
<div className="absolute inset-0 bg-linear-to-t from-black via-black/50 to-transparent"/> <div className="absolute inset-0 bg-gradient-to-t from-black via-black/50 to-transparent"/>
{onBack && (<div className="absolute top-4 right-4 z-10"> {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"> <Button variant="ghost" size="icon" onClick={onBack} className="text-white hover:bg-white/20 hover:text-white">
<XCircle className="h-5 w-5"/> <XCircle className="h-5 w-5"/>
@@ -564,7 +563,7 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
Filter Albums Filter Albums
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="sm:max-w-125 h-[80vh] flex flex-col"> <DialogContent className="sm:max-w-[500px] h-[80vh] flex flex-col">
<DialogHeader> <DialogHeader>
<DialogTitle>Select Albums</DialogTitle> <DialogTitle>Select Albums</DialogTitle>
</DialogHeader> </DialogHeader>
@@ -635,7 +634,7 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
</Tooltip>)} </Tooltip>)}
</div> </div>
</div> </div>
{isDownloading && (<DownloadProgress progress={downloadProgress} remainingCount={downloadRemainingCount} currentTrack={currentDownloadInfo} onStop={onStopDownload}/>)} {isDownloading && (<DownloadProgress progress={downloadProgress} currentTrack={currentDownloadInfo} onStop={onStopDownload}/>)}
<SearchAndSort searchQuery={searchQuery} sortBy={sortBy} onSearchChange={onSearchChange} onSortChange={onSortChange}/> <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}/> <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>)} </div>)}
+2 -5
View File
@@ -3,17 +3,14 @@ import { Progress } from "@/components/ui/progress";
import { StopCircle } from "lucide-react"; import { StopCircle } from "lucide-react";
interface DownloadProgressProps { interface DownloadProgressProps {
progress: number; progress: number;
remainingCount?: number;
currentTrack: { currentTrack: {
name: string; name: string;
artists: string; artists: string;
} | null; } | null;
onStop: () => void; onStop: () => void;
} }
export function DownloadProgress({ progress, remainingCount = 0, currentTrack, onStop }: DownloadProgressProps) { export function DownloadProgress({ progress, currentTrack, onStop }: DownloadProgressProps) {
const clampedProgress = Math.min(100, Math.max(0, progress)); 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"> return (<div className="w-full space-y-2 mt-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Progress value={clampedProgress} className="h-2 flex-1"/> <Progress value={clampedProgress} className="h-2 flex-1"/>
@@ -23,7 +20,7 @@ export function DownloadProgress({ progress, remainingCount = 0, currentTrack, o
</Button> </Button>
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{clampedProgress}% {remainingLabel} -{" "} {clampedProgress}% -{" "}
{currentTrack {currentTrack
? `${currentTrack.name} - ${currentTrack.artists}` ? `${currentTrack.name} - ${currentTrack.artists}`
: "Preparing download..."} : "Preparing download..."}
+14 -60
View File
@@ -9,8 +9,7 @@ import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, Pagi
import { GetDownloadHistory, ClearDownloadHistory, GetPreviewURL, GetFetchHistory, DeleteDownloadHistoryItem, DeleteFetchHistoryItem, ClearFetchHistoryByType } from "../../wailsjs/go/main/App"; import { GetDownloadHistory, ClearDownloadHistory, GetPreviewURL, GetFetchHistory, DeleteDownloadHistoryItem, DeleteFetchHistoryItem, ClearFetchHistoryByType } from "../../wailsjs/go/main/App";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { openExternal } from "@/lib/utils"; import { openExternal } from "@/lib/utils";
import { getPreviewVolume } from "@/lib/preview"; import { SPOTIFY_PREVIEW_VOLUME } from "@/lib/preview";
import { createPreviewPlayback, type PreviewPlayback } from "@/lib/preview-player";
import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons"; import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons";
const formatDate = (timestamp: number) => { const formatDate = (timestamp: number) => {
const date = new Date(timestamp * 1000); const date = new Date(timestamp * 1000);
@@ -22,37 +21,6 @@ const formatDate = (timestamp: number) => {
const seconds = String(date.getSeconds()).padStart(2, '0'); const seconds = String(date.getSeconds()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; 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 { interface DownloadHistoryItem {
id: string; id: string;
spotify_id: string; spotify_id: string;
@@ -89,7 +57,7 @@ export function HistoryPage({ onHistorySelect }: HistoryPageProps) {
const [downloadSortBy, setDownloadSortBy] = useState("default"); const [downloadSortBy, setDownloadSortBy] = useState("default");
const [downloadCurrentPage, setDownloadCurrentPage] = useState(1); const [downloadCurrentPage, setDownloadCurrentPage] = useState(1);
const [playingPreviewId, setPlayingPreviewId] = useState<string | null>(null); const [playingPreviewId, setPlayingPreviewId] = useState<string | null>(null);
const playbackRef = useRef<PreviewPlayback | null>(null); const audioRef = useRef<HTMLAudioElement | null>(null);
const [fetchHistory, setFetchHistory] = useState<FetchHistoryItem[]>([]); const [fetchHistory, setFetchHistory] = useState<FetchHistoryItem[]>([]);
const [filteredFetchHistory, setFilteredFetchHistory] = useState<FetchHistoryItem[]>([]); const [filteredFetchHistory, setFilteredFetchHistory] = useState<FetchHistoryItem[]>([]);
const [activeFetchTab, setActiveFetchTab] = useState("track"); const [activeFetchTab, setActiveFetchTab] = useState("track");
@@ -154,8 +122,9 @@ export function HistoryPage({ onHistorySelect }: HistoryPageProps) {
}, [activeTab]); }, [activeTab]);
useEffect(() => { useEffect(() => {
return () => { return () => {
playbackRef.current?.destroy(); if (audioRef.current) {
playbackRef.current = null; audioRef.current.pause();
}
}; };
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -211,35 +180,20 @@ export function HistoryPage({ onHistorySelect }: HistoryPageProps) {
}, [fetchSearchQuery, activeFetchTab]); }, [fetchSearchQuery, activeFetchTab]);
const handlePreview = async (id: string, spotifyId: string) => { const handlePreview = async (id: string, spotifyId: string) => {
if (playingPreviewId === id) { if (playingPreviewId === id) {
playbackRef.current?.destroy(); audioRef.current?.pause();
playbackRef.current = null;
setPlayingPreviewId(null); setPlayingPreviewId(null);
return; return;
} }
if (playbackRef.current) { if (audioRef.current) {
playbackRef.current.destroy(); audioRef.current.pause();
playbackRef.current = null;
} }
try { try {
const url = await GetPreviewURL(spotifyId); const url = await GetPreviewURL(spotifyId);
if (url) { if (url) {
const playback = await createPreviewPlayback(url, getPreviewVolume()); const audio = new Audio(url);
const audio = playback.audio; audioRef.current = audio;
playbackRef.current = playback; audio.volume = SPOTIFY_PREVIEW_VOLUME;
audio.onended = () => { audio.onended = () => setPlayingPreviewId(null);
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(); audio.play();
setPlayingPreviewId(id); setPlayingPreviewId(id);
} }
@@ -317,7 +271,7 @@ export function HistoryPage({ onHistorySelect }: HistoryPageProps) {
<Input placeholder="Search downloads..." value={downloadSearchQuery} onChange={(e) => setDownloadSearchQuery(e.target.value)} className="pl-8 h-9"/> <Input placeholder="Search downloads..." value={downloadSearchQuery} onChange={(e) => setDownloadSearchQuery(e.target.value)} className="pl-8 h-9"/>
</div> </div>
<Select value={downloadSortBy} onValueChange={setDownloadSortBy}> <Select value={downloadSortBy} onValueChange={setDownloadSortBy}>
<SelectTrigger className="w-45 h-9"> <SelectTrigger className="w-[180px] h-9">
<ArrowUpDown className="mr-2 h-4 w-4"/> <ArrowUpDown className="mr-2 h-4 w-4"/>
<SelectValue placeholder="Sort by"/> <SelectValue placeholder="Sort by"/>
</SelectTrigger> </SelectTrigger>
@@ -378,7 +332,7 @@ export function HistoryPage({ onHistorySelect }: HistoryPageProps) {
<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"> <div className="flex flex-col items-start gap-1">
<span className="text-xs font-bold text-foreground"> <span className="text-xs font-bold text-foreground">
{getHistoryFormatLabel(item)} {['HI_RES_LOSSLESS', 'LOSSLESS', 'flac', '6', '7', '27'].includes(item.format?.toLowerCase() || '') ? 'FLAC' : item.format?.toUpperCase()}
</span> </span>
{item.quality && <span className="text-[11px] text-muted-foreground leading-none whitespace-nowrap">{item.quality}</span>} {item.quality && <span className="text-[11px] text-muted-foreground leading-none whitespace-nowrap">{item.quality}</span>}
</div> </div>
+2 -3
View File
@@ -41,7 +41,6 @@ interface PlaylistInfoProps {
isDownloading: boolean; isDownloading: boolean;
bulkDownloadType: "all" | "selected" | null; bulkDownloadType: "all" | "selected" | null;
downloadProgress: number; downloadProgress: number;
downloadRemainingCount: number;
currentDownloadInfo: { currentDownloadInfo: {
name: string; name: string;
artists: string; artists: string;
@@ -89,7 +88,7 @@ interface PlaylistInfoProps {
onTrackClick: (track: TrackMetadata) => void; onTrackClick: (track: TrackMetadata) => void;
onBack?: () => void; onBack?: () => void;
} }
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) { 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) {
const settings = getSettings(); const settings = getSettings();
const playlistName = playlistInfo.owner.name; const playlistName = playlistInfo.owner.name;
const playlistFolderName = buildPlaylistFolderName(playlistName, playlistInfo.owner.display_name, settings.playlistOwnerFolderName); const playlistFolderName = buildPlaylistFolderName(playlistName, playlistInfo.owner.display_name, settings.playlistOwnerFolderName);
@@ -236,7 +235,7 @@ export function PlaylistInfo({ playlistInfo, trackList, searchQuery, sortBy, sel
</TooltipContent> </TooltipContent>
</Tooltip>)} </Tooltip>)}
</div> </div>
{isDownloading && (<DownloadProgress progress={downloadProgress} remainingCount={downloadRemainingCount} currentTrack={currentDownloadInfo} onStop={onStopDownload}/>)} {isDownloading && (<DownloadProgress progress={downloadProgress} currentTrack={currentDownloadInfo} onStop={onStopDownload}/>)}
</div> </div>
</div> </div>
</CardContent> </CardContent>
+49 -286
View File
@@ -1,37 +1,28 @@
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback } from "react";
import { flushSync } from "react-dom"; import { flushSync } from "react-dom";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { InputWithContext } from "@/components/ui/input-with-context"; import { InputWithContext } from "@/components/ui/input-with-context";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select";
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
import { FolderOpen, Save, RotateCcw, Info, ArrowRight, MonitorCog, FolderCog, Router, FolderLock, Plus, Trash2, ExternalLink } from "lucide-react"; import { FolderOpen, Save, RotateCcw, Info, ArrowRight, MonitorCog, FolderCog, Router, FolderLock } from "lucide-react";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
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 { 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 { themes, applyTheme } from "@/lib/themes"; import { themes, applyTheme } from "@/lib/themes";
import { SelectFolder, OpenConfigFolder, CheckCustomTidalAPI } from "../../wailsjs/go/main/App"; import { SelectFolder, OpenConfigFolder } from "../../wailsjs/go/main/App";
import { toastWithSound as toast } from "@/lib/toast-with-sound"; import { toastWithSound as toast } from "@/lib/toast-with-sound";
import { openExternal } from "@/lib/utils";
import { ApiStatusTab } from "./ApiStatusTab"; import { ApiStatusTab } from "./ApiStatusTab";
import { AmazonIcon, QobuzIcon, SonglinkIcon, SongstatsIcon, TidalIcon } from "./PlatformIcons"; import { AmazonIcon, QobuzIcon, SonglinkIcon, SongstatsIcon, TidalIcon } from "./PlatformIcons";
interface SettingsPageProps { interface SettingsPageProps {
onUnsavedChangesChange?: (hasUnsavedChanges: boolean) => void; onUnsavedChangesChange?: (hasUnsavedChanges: boolean) => void;
onResetRequest?: (resetFn: () => void) => void; onResetRequest?: (resetFn: () => void) => void;
} }
type CustomTidalApiStatus = "idle" | "checking" | "online" | "offline";
export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: SettingsPageProps) { export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: SettingsPageProps) {
const [savedSettings, setSavedSettings] = useState<SettingsType>(getSettings()); const [savedSettings, setSavedSettings] = useState<SettingsType>(getSettings());
const [tempSettings, setTempSettings] = useState<SettingsType>(savedSettings); const [tempSettings, setTempSettings] = useState<SettingsType>(savedSettings);
const [isDark, setIsDark] = useState(document.documentElement.classList.contains("dark")); const [isDark, setIsDark] = useState(document.documentElement.classList.contains("dark"));
const [showResetConfirm, setShowResetConfirm] = useState(false); 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 hasUnsavedChanges = JSON.stringify(savedSettings) !== JSON.stringify(tempSettings);
const resetToSaved = useCallback(() => { const resetToSaved = useCallback(() => {
const freshSavedSettings = getSettings(); const freshSavedSettings = getSettings();
@@ -64,20 +55,14 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
useEffect(() => { useEffect(() => {
applyThemeMode(tempSettings.themeMode); applyThemeMode(tempSettings.themeMode);
applyTheme(tempSettings.theme); applyTheme(tempSettings.theme);
applyFont(tempSettings.fontFamily, tempSettings.customFonts); applyFont(tempSettings.fontFamily);
setTimeout(() => { setTimeout(() => {
setIsDark(document.documentElement.classList.contains("dark")); setIsDark(document.documentElement.classList.contains("dark"));
}, 0); }, 0);
}, [tempSettings.themeMode, tempSettings.theme, tempSettings.fontFamily, tempSettings.customFonts]); }, [tempSettings.themeMode, tempSettings.theme, tempSettings.fontFamily]);
useEffect(() => {
if (showAddFontDialog && parsedAddFont) {
loadGoogleFontUrl(parsedAddFont.url, "spotiflac-add-font-preview");
}
}, [showAddFontDialog, parsedAddFont]);
useEffect(() => { useEffect(() => {
const loadDefaults = async () => { const loadDefaults = async () => {
const currentSettings = getSettings(); if (!savedSettings.downloadPath) {
if (!currentSettings.downloadPath) {
const settingsWithDefaults = await getSettingsWithDefaults(); const settingsWithDefaults = await getSettingsWithDefaults();
setSavedSettings(settingsWithDefaults); setSavedSettings(settingsWithDefaults);
setTempSettings(settingsWithDefaults); setTempSettings(settingsWithDefaults);
@@ -86,14 +71,6 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
}; };
loadDefaults(); loadDefaults();
}, []); }, []);
useEffect(() => {
const syncCustomFonts = async () => {
const customFonts = await loadCustomFonts();
setSavedSettings((prev) => ({ ...prev, customFonts }));
setTempSettings((prev) => ({ ...prev, customFonts }));
};
void syncCustomFonts();
}, []);
const handleSave = async () => { const handleSave = async () => {
await saveSettings(tempSettings); await saveSettings(tempSettings);
setSavedSettings(tempSettings); setSavedSettings(tempSettings);
@@ -106,7 +83,7 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
setSavedSettings(defaultSettings); setSavedSettings(defaultSettings);
applyThemeMode(defaultSettings.themeMode); applyThemeMode(defaultSettings.themeMode);
applyTheme(defaultSettings.theme); applyTheme(defaultSettings.theme);
applyFont(defaultSettings.fontFamily, defaultSettings.customFonts); applyFont(defaultSettings.fontFamily);
setShowResetConfirm(false); setShowResetConfirm(false);
toast.success("Settings reset to default"); toast.success("Settings reset to default");
}; };
@@ -122,100 +99,18 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
toast.error(`Error selecting folder: ${error}`); 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") => { const handleTidalQualityChange = async (value: "LOSSLESS" | "HI_RES_LOSSLESS") => {
setTempSettings((prev) => ({ ...prev, tidalQuality: value })); setTempSettings((prev) => ({ ...prev, tidalQuality: value }));
}; };
const handleTidalVariantChange = (value: "tidal" | "alt") => {
setTempSettings((prev) => ({ ...prev, tidalVariant: value }));
};
const handleQobuzQualityChange = (value: "6" | "7" | "27") => { const handleQobuzQualityChange = (value: "6" | "7" | "27") => {
setTempSettings((prev) => ({ ...prev, qobuzQuality: value })); setTempSettings((prev) => ({ ...prev, qobuzQuality: value }));
}; };
const handleAutoQualityChange = async (value: "16" | "24") => { const handleAutoQualityChange = async (value: "16" | "24") => {
setTempSettings((prev) => ({ ...prev, autoQuality: value })); 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"); const [activeTab, setActiveTab] = useState<"general" | "files" | "api">("general");
return (<div className="space-y-4 h-full flex flex-col"> return (<div className="space-y-4 h-full flex flex-col">
<div className="flex items-center justify-between shrink-0"> <div className="flex items-center justify-between shrink-0">
@@ -312,39 +207,18 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="font">Font</Label> <Label htmlFor="font">Font</Label>
<div className="flex flex-wrap items-center gap-2">
<Select value={tempSettings.fontFamily} onValueChange={(value: FontFamily) => setTempSettings((prev) => ({ ...prev, fontFamily: value }))}> <Select value={tempSettings.fontFamily} onValueChange={(value: FontFamily) => setTempSettings((prev) => ({ ...prev, fontFamily: value }))}>
<SelectTrigger id="font" className="max-w-full min-w-40"> <SelectTrigger id="font">
<SelectValue placeholder="Select a font"/> <SelectValue placeholder="Select a font"/>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{fontOptions.map((font) => { {FONT_OPTIONS.map((font) => (<SelectItem key={font.value} value={font.value}>
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 }}> <span style={{ fontFamily: font.fontFamily }}>
{font.label} {font.label}
</span> </span>
</SelectItem>); </SelectItem>))}
})}
</SelectContent> </SelectContent>
</Select> </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>
<div className="flex items-center gap-3 pt-2"> <div className="flex items-center gap-3 pt-2">
@@ -366,7 +240,7 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
...prev, ...prev,
linkResolver: value, linkResolver: value,
}))}> }))}>
<SelectTrigger id="link-resolver" className="h-9 w-fit min-w-35"> <SelectTrigger id="link-resolver" className="h-9 w-fit min-w-[140px]">
<SelectValue placeholder="Select a link resolver"/> <SelectValue placeholder="Select a link resolver"/>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -399,8 +273,8 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="downloader">Source</Label> <Label htmlFor="downloader">Source</Label>
<div className="flex items-center gap-3 flex-wrap"> <div className="flex gap-2 flex-wrap">
<Select value={tempSettings.downloader} onValueChange={(value: SettingsType["downloader"]) => setTempSettings((prev) => ({ <Select value={tempSettings.downloader} onValueChange={(value: any) => setTempSettings((prev) => ({
...prev, ...prev,
downloader: value, downloader: value,
}))}> }))}>
@@ -432,11 +306,11 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
</Select> </Select>
{tempSettings.downloader === "auto" && (<> {tempSettings.downloader === "auto" && (<>
<Select value={tempSettings.autoOrder || "tidal-qobuz-amazon"} onValueChange={(value: string) => setTempSettings((prev) => ({ <Select value={tempSettings.autoOrder || "tidal-qobuz-amazon"} onValueChange={(value: any) => setTempSettings((prev) => ({
...prev, ...prev,
autoOrder: value, autoOrder: value,
}))}> }))}>
<SelectTrigger className="h-9 w-fit min-w-35"> <SelectTrigger className="h-9 w-fit min-w-[140px]">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -553,7 +427,9 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
</Select> </Select>
</>)} </>)}
{tempSettings.downloader === "tidal" && (<Select value={tempSettings.tidalQuality} onValueChange={handleTidalQualityChange}> {tempSettings.downloader === "tidal" && (tempSettings.tidalVariant === "alt" ? (<div className="h-9 px-3 flex items-center text-sm font-medium border border-input rounded-md bg-muted/30 text-muted-foreground whitespace-nowrap cursor-default">
16-bit/44.1kHz
</div>) : (<Select value={tempSettings.tidalQuality} onValueChange={handleTidalQualityChange}>
<SelectTrigger className="h-9 w-fit"> <SelectTrigger className="h-9 w-fit">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
@@ -563,7 +439,7 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
24-bit/48kHz 24-bit/48kHz
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select>)} </Select>))}
{tempSettings.downloader === "qobuz" && (<Select value={tempSettings.qobuzQuality} onValueChange={handleQobuzQualityChange}> {tempSettings.downloader === "qobuz" && (<Select value={tempSettings.qobuzQuality} onValueChange={handleQobuzQualityChange}>
<SelectTrigger className="h-9 w-fit"> <SelectTrigger className="h-9 w-fit">
@@ -581,12 +457,27 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
</div> </div>
{(tempSettings.downloader === "tidal" || tempSettings.downloader === "auto") && (<div className="space-y-2 pt-2">
<Label htmlFor="tidal-variant">Tidal Variant</Label>
<Select value={tempSettings.tidalVariant || "tidal"} onValueChange={handleTidalVariantChange}>
<SelectTrigger id="tidal-variant" className="h-9 w-fit min-w-[160px]">
<SelectValue placeholder="Select Tidal variant"/>
</SelectTrigger>
<SelectContent>
<SelectItem value="tidal">Tidal</SelectItem>
<SelectItem value="alt">Tidal Alt.</SelectItem>
</SelectContent>
</Select>
</div>)}
{((tempSettings.downloader === "tidal" && {((tempSettings.downloader === "tidal" &&
tempSettings.tidalVariant !== "alt" &&
tempSettings.tidalQuality === "HI_RES_LOSSLESS") || tempSettings.tidalQuality === "HI_RES_LOSSLESS") ||
(tempSettings.downloader === "qobuz" && (tempSettings.downloader === "qobuz" &&
tempSettings.qobuzQuality === "27") || tempSettings.qobuzQuality === "27") ||
(tempSettings.downloader === "auto" && (tempSettings.downloader === "auto" &&
tempSettings.autoQuality === "24")) && (<div className="flex items-center gap-3 pt-2"> 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) => ({ <Switch id="allow-fallback" checked={tempSettings.allowFallback} onCheckedChange={(checked) => setTempSettings((prev) => ({
...prev, ...prev,
allowFallback: checked, allowFallback: checked,
@@ -594,25 +485,22 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
<Label htmlFor="allow-fallback" className="text-sm font-normal cursor-pointer"> <Label htmlFor="allow-fallback" className="text-sm font-normal cursor-pointer">
Allow Quality Fallback (16-bit) Allow Quality Fallback (16-bit)
</Label> </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>)}
</div> </div>
<div className="border-t pt-2"/> <div className="border-t pt-6"/>
<div className="space-y-4"> <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"> <div className="flex items-center gap-3">
<Switch id="embed-max-quality-cover" checked={tempSettings.embedMaxQualityCover} onCheckedChange={(checked) => setTempSettings((prev) => ({ <Switch id="embed-max-quality-cover" checked={tempSettings.embedMaxQualityCover} onCheckedChange={(checked) => setTempSettings((prev) => ({
...prev, ...prev,
@@ -640,15 +528,6 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
Use Single Genre Use Single Genre
</Label> </Label>
</div>)} </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> </div>
</div>)} </div>)}
@@ -766,23 +645,6 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
</div> </div>
<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="space-y-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Label className="text-sm">Filename Format</Label> <Label className="text-sm">Filename Format</Label>
@@ -857,119 +719,20 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
.flac .flac
</span> </span>
</p>)} </p>)}
</div>
</div> </div>
</div>)} </div>)}
{activeTab === "api" && (<ApiStatusTab />)} {activeTab === "api" && (<ApiStatusTab />)}
</div> </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}> <Dialog open={showResetConfirm} onOpenChange={setShowResetConfirm}>
<DialogContent className="max-w-md [&>button]:hidden"> <DialogContent className="max-w-md [&>button]:hidden">
<DialogHeader> <DialogHeader>
<DialogTitle>Reset to Default?</DialogTitle> <DialogTitle>Reset to Default?</DialogTitle>
<DialogDescription> <DialogDescription>
This will reset all settings to their default values. Your custom This will reset all settings to their default values. Your custom
font list will be kept. configurations will be lost.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
+2 -46
View File
@@ -1,9 +1,6 @@
import { X, Minus, Maximize, SlidersHorizontal, Globe, Eye, EyeOff } from "lucide-react"; import { X, Minus, Maximize, SlidersHorizontal, Globe, Eye, EyeOff } from "lucide-react";
import { WindowMinimise, WindowToggleMaximise, Quit } from "../../wailsjs/runtime/runtime"; import { WindowMinimise, WindowToggleMaximise, Quit } from "../../wailsjs/runtime/runtime";
import { Menubar, MenubarContent, MenubarMenu, MenubarItem, MenubarTrigger, MenubarLabel, MenubarSeparator } from "@/components/ui/menubar"; 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 { fetchCurrentIPInfo } from "@/lib/api";
import type { CurrentIPInfo } from "@/types/api"; import type { CurrentIPInfo } from "@/types/api";
import { openExternal } from "@/lib/utils"; import { openExternal } from "@/lib/utils";
@@ -27,12 +24,7 @@ const SPOTIFY_BLOCKED_COUNTRY_CODES = new Set([
"TM", "TM",
"YE", "YE",
]); ]);
interface SettingsUpdatedDetail {
previewVolume?: number;
}
export function TitleBar() { export function TitleBar() {
const initialSettings = getSettings();
const [previewVolume, setPreviewVolume] = useState(initialSettings.previewVolume ?? 100);
const [currentIPInfo, setCurrentIPInfo] = useState<CurrentIPInfo | null>(null); const [currentIPInfo, setCurrentIPInfo] = useState<CurrentIPInfo | null>(null);
const [isLoadingCurrentIPInfo, setIsLoadingCurrentIPInfo] = useState(false); const [isLoadingCurrentIPInfo, setIsLoadingCurrentIPInfo] = useState(false);
const [currentIPInfoError, setCurrentIPInfoError] = useState(""); const [currentIPInfoError, setCurrentIPInfoError] = useState("");
@@ -41,16 +33,6 @@ export function TitleBar() {
useEffect(() => { useEffect(() => {
currentIPInfoRef.current = currentIPInfo; currentIPInfoRef.current = currentIPInfo;
}, [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?: { const loadCurrentIPInfo = async (options?: {
silent?: boolean; silent?: boolean;
}) => { }) => {
@@ -106,22 +88,6 @@ export function TitleBar() {
const handleClose = () => { const handleClose = () => {
Quit(); 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 detectedCountryCode = currentIPInfo?.country_code?.toUpperCase() || "";
const detectedFlagPath = detectedCountryCode ? `/assets/flags/${detectedCountryCode.toLowerCase()}.svg` : ""; const detectedFlagPath = detectedCountryCode ? `/assets/flags/${detectedCountryCode.toLowerCase()}.svg` : "";
const isSpotifyBlockedCountry = detectedCountryCode !== "" && SPOTIFY_BLOCKED_COUNTRY_CODES.has(detectedCountryCode); const isSpotifyBlockedCountry = detectedCountryCode !== "" && SPOTIFY_BLOCKED_COUNTRY_CODES.has(detectedCountryCode);
@@ -136,17 +102,7 @@ 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"> <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"/> <SlidersHorizontal className="w-3.5 h-3.5"/>
</MenubarTrigger> </MenubarTrigger>
<MenubarContent align="end" className="min-w-70"> <MenubarContent align="end" className="min-w-[280px]">
<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"> <div className="flex items-center gap-1.5 px-2 py-1.5">
<MenubarLabel className="p-0">Network</MenubarLabel> <MenubarLabel className="p-0">Network</MenubarLabel>
{isSpotifyBlockedCountry && (<span className="text-xs font-medium text-destructive"> {isSpotifyBlockedCountry && (<span className="text-xs font-medium text-destructive">
@@ -156,7 +112,7 @@ export function TitleBar() {
<div className="px-2 py-1.5 space-y-1"> <div className="px-2 py-1.5 space-y-1">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
{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"/>)} {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"/>)}
<span className="font-mono text-xs truncate"> <span className="font-mono text-xs truncate">
{isLoadingCurrentIPInfo {isLoadingCurrentIPInfo
? "Detecting..." ? "Detecting..."
+5 -15
View File
@@ -37,24 +37,14 @@ function SelectContent({ className, children, position = "popper", align = "cent
function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) { 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}/>); 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, indicatorPosition = "right", trailingAction, ...props }: React.ComponentProps<typeof SelectPrimitive.Item> & { function SelectItem({ className, children, ...props }: React.ComponentProps<typeof SelectPrimitive.Item>) {
indicatorPosition?: "right" | "inline"; 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}>
trailingAction?: React.ReactNode; <span className="absolute right-2 flex size-3.5 items-center justify-center">
}) {
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>
{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> <SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4"/> <CheckIcon className="size-4"/>
</SelectPrimitive.ItemIndicator> </SelectPrimitive.ItemIndicator>
</span>) : null} </span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>); </SelectPrimitive.Item>);
} }
function SelectSeparator({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Separator>) { function SelectSeparator({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Separator>) {
-17
View File
@@ -1,17 +0,0 @@
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 };
+42 -48
View File
@@ -36,17 +36,13 @@ const GetTrackISRC = (spotifyId: string): Promise<string> => (window as any)["go
async function resolveTemplateISRC(settings: { async function resolveTemplateISRC(settings: {
folderTemplate?: string; folderTemplate?: string;
filenameTemplate?: string; filenameTemplate?: string;
existingFileCheckMode?: string;
}, spotifyId?: string): Promise<string> { }, spotifyId?: string): Promise<string> {
if (!spotifyId) { if (!spotifyId) {
return ""; return "";
} }
const folderTemplate = settings.folderTemplate || ""; const folderTemplate = settings.folderTemplate || "";
const filenameTemplate = settings.filenameTemplate || ""; const filenameTemplate = settings.filenameTemplate || "";
const shouldResolveISRC = settings.existingFileCheckMode === "isrc" || if (!folderTemplate.includes("{isrc}") && !filenameTemplate.includes("{isrc}")) {
folderTemplate.includes("{isrc}") ||
filenameTemplate.includes("{isrc}");
if (!shouldResolveISRC) {
return ""; return "";
} }
try { try {
@@ -56,18 +52,26 @@ async function resolveTemplateISRC(settings: {
return ""; return "";
} }
} }
function getTidalVariant(settings: any): "tidal" | "alt" {
return settings?.tidalVariant === "alt" ? "alt" : "tidal";
}
function isTidalAltVariant(settings: any): boolean {
return getTidalVariant(settings) === "alt";
}
function getTidalAudioFormat(settings: any, mode: "single" | "auto"): "LOSSLESS" | "HI_RES_LOSSLESS" { function getTidalAudioFormat(settings: any, mode: "single" | "auto"): "LOSSLESS" | "HI_RES_LOSSLESS" {
if (isTidalAltVariant(settings)) {
return "LOSSLESS";
}
if (mode === "auto") { if (mode === "auto") {
return (settings.autoQuality || "24") === "24" ? "HI_RES_LOSSLESS" : "LOSSLESS"; return (settings.autoQuality || "24") === "24" ? "HI_RES_LOSSLESS" : "LOSSLESS";
} }
return settings.tidalQuality || "LOSSLESS"; return settings.tidalQuality || "LOSSLESS";
} }
function shouldFetchStreamingURLs(order: string[]): boolean { function shouldFetchStreamingURLs(order: string[], settings: any): boolean {
return order.includes("amazon") || order.includes("tidal"); return order.includes("amazon") || (order.includes("tidal") && !isTidalAltVariant(settings));
} }
export function useDownload(region: string) { export function useDownload(region: string) {
const [downloadProgress, setDownloadProgress] = useState<number>(0); const [downloadProgress, setDownloadProgress] = useState<number>(0);
const [downloadRemainingCount, setDownloadRemainingCount] = useState<number>(0);
const [isDownloading, setIsDownloading] = useState(false); const [isDownloading, setIsDownloading] = useState(false);
const [downloadingTrack, setDownloadingTrack] = useState<string | null>(null); const [downloadingTrack, setDownloadingTrack] = useState<string | null>(null);
const [bulkDownloadType, setBulkDownloadType] = useState<"all" | "selected" | null>(null); const [bulkDownloadType, setBulkDownloadType] = useState<"all" | "selected" | null>(null);
@@ -79,19 +83,10 @@ export function useDownload(region: string) {
artists: string; artists: string;
} | null>(null); } | null>(null);
const shouldStopDownloadRef = useRef(false); 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 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 service = settings.downloader;
const query = trackName && artistName ? `${trackName} ${artistName} ` : undefined; const query = trackName && artistName ? `${trackName} ${artistName} ` : undefined;
const os = settings.operatingSystem; 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 outputDir = settings.downloadPath;
let useAlbumTrackNumber = false; let useAlbumTrackNumber = false;
const placeholder = "__SLASH_PLACEHOLDER__"; const placeholder = "__SLASH_PLACEHOLDER__";
@@ -194,8 +189,10 @@ export function useDownload(region: string) {
} }
if (service === "auto") { if (service === "auto") {
const order = (settings.autoOrder || "tidal-amazon-qobuz").split("-"); const order = (settings.autoOrder || "tidal-amazon-qobuz").split("-");
const tidalVariant = getTidalVariant(settings);
const tidalLabel = tidalVariant === "alt" ? "Tidal Alt." : "Tidal";
let streamingURLs: any = null; let streamingURLs: any = null;
if (spotifyId && shouldFetchStreamingURLs(order)) { if (spotifyId && shouldFetchStreamingURLs(order, settings)) {
try { try {
const { GetStreamingURLs } = await import("../../wailsjs/go/main/App"); const { GetStreamingURLs } = await import("../../wailsjs/go/main/App");
const urlsJson = await GetStreamingURLs(spotifyId, region); const urlsJson = await GetStreamingURLs(spotifyId, region);
@@ -212,9 +209,9 @@ export function useDownload(region: string) {
const is24Bit = (settings.autoQuality || "24") === "24"; const is24Bit = (settings.autoQuality || "24") === "24";
const qobuzQuality = is24Bit ? "27" : "6"; const qobuzQuality = is24Bit ? "27" : "6";
for (const s of order) { for (const s of order) {
if (s === "tidal" && streamingURLs?.tidal_url) { if (s === "tidal" && ((tidalVariant === "alt" && spotifyId) || streamingURLs?.tidal_url)) {
try { try {
logger.debug(`trying Tidal for: ${trackName} - ${artistName}`); logger.debug(`trying ${tidalLabel} for: ${trackName} - ${artistName}`);
const response = await downloadTrack({ const response = await downloadTrack({
service: "tidal", service: "tidal",
query, query,
@@ -232,11 +229,11 @@ export function useDownload(region: string) {
spotify_id: spotifyId, spotify_id: spotifyId,
embed_lyrics: settings.embedLyrics, embed_lyrics: settings.embedLyrics,
embed_max_quality_cover: settings.embedMaxQualityCover, embed_max_quality_cover: settings.embedMaxQualityCover,
service_url: streamingURLs?.tidal_url, service_url: tidalVariant === "alt" ? undefined : streamingURLs?.tidal_url,
tidal_variant: tidalVariant,
duration: durationSeconds, duration: durationSeconds,
item_id: itemID, item_id: itemID,
audio_format: tidalQuality, audio_format: tidalQuality,
tidal_api_url: customTidalApi,
spotify_track_number: spotifyTrackNumber, spotify_track_number: spotifyTrackNumber,
spotify_disc_number: spotifyDiscNumber, spotify_disc_number: spotifyDiscNumber,
spotify_total_tracks: spotifyTotalTracks, spotify_total_tracks: spotifyTotalTracks,
@@ -249,17 +246,17 @@ export function useDownload(region: string) {
embed_genre: settings.embedGenre, embed_genre: settings.embedGenre,
}); });
if (response.success) { if (response.success) {
logger.success(`Tidal: ${trackName} - ${artistName}`); logger.success(`${tidalLabel}: ${trackName} - ${artistName}`);
return response; return response;
} }
const errMsg = response.error || response.message || "Failed"; const errMsg = response.error || response.message || "Failed";
fallbackErrors.push(`[Tidal] ${errMsg}`); fallbackErrors.push(`[${tidalLabel}] ${errMsg}`);
lastResponse = response; lastResponse = response;
logger.warning(`Tidal failed, trying next...`); logger.warning(`${tidalLabel} failed, trying next...`);
} }
catch (err) { catch (err) {
logger.error(`Tidal error: ${err}`); logger.error(`${tidalLabel} error: ${err}`);
fallbackErrors.push(`[Tidal] ${String(err)}`); fallbackErrors.push(`[${tidalLabel}] ${String(err)}`);
lastResponse = { success: false, error: String(err) }; lastResponse = { success: false, error: String(err) };
} }
} }
@@ -397,7 +394,7 @@ export function useDownload(region: string) {
duration: durationSecondsForFallback, duration: durationSecondsForFallback,
item_id: itemID, item_id: itemID,
audio_format: audioFormat, audio_format: audioFormat,
tidal_api_url: service === "tidal" ? customTidalApi : undefined, tidal_variant: service === "tidal" ? getTidalVariant(settings) : undefined,
spotify_track_number: spotifyTrackNumber, spotify_track_number: spotifyTrackNumber,
spotify_disc_number: spotifyDiscNumber, spotify_disc_number: spotifyDiscNumber,
spotify_total_tracks: spotifyTotalTracks, spotify_total_tracks: spotifyTotalTracks,
@@ -478,8 +475,10 @@ export function useDownload(region: string) {
} }
if (service === "auto") { if (service === "auto") {
const order = (settings.autoOrder || "tidal-amazon-qobuz").split("-"); const order = (settings.autoOrder || "tidal-amazon-qobuz").split("-");
const tidalVariant = getTidalVariant(settings);
const tidalLabel = tidalVariant === "alt" ? "Tidal Alt." : "Tidal";
let streamingURLs: any = null; let streamingURLs: any = null;
if (spotifyId && shouldFetchStreamingURLs(order)) { if (spotifyId && shouldFetchStreamingURLs(order, settings)) {
try { try {
const { GetStreamingURLs } = await import("../../wailsjs/go/main/App"); const { GetStreamingURLs } = await import("../../wailsjs/go/main/App");
const urlsJson = await GetStreamingURLs(spotifyId, region); const urlsJson = await GetStreamingURLs(spotifyId, region);
@@ -496,9 +495,9 @@ export function useDownload(region: string) {
const is24Bit = (settings.autoQuality || "24") === "24"; const is24Bit = (settings.autoQuality || "24") === "24";
const qobuzQuality = is24Bit ? "27" : "6"; const qobuzQuality = is24Bit ? "27" : "6";
for (const s of order) { for (const s of order) {
if (s === "tidal" && streamingURLs?.tidal_url) { if (s === "tidal" && ((tidalVariant === "alt" && spotifyId) || streamingURLs?.tidal_url)) {
try { try {
logger.debug(`trying Tidal for: ${trackName} - ${artistName}`); logger.debug(`trying ${tidalLabel} for: ${trackName} - ${artistName}`);
const response = await downloadTrack({ const response = await downloadTrack({
service: "tidal", service: "tidal",
query, query,
@@ -516,7 +515,8 @@ export function useDownload(region: string) {
spotify_id: spotifyId, spotify_id: spotifyId,
embed_lyrics: settings.embedLyrics, embed_lyrics: settings.embedLyrics,
embed_max_quality_cover: settings.embedMaxQualityCover, embed_max_quality_cover: settings.embedMaxQualityCover,
service_url: streamingURLs?.tidal_url, service_url: tidalVariant === "alt" ? undefined : streamingURLs?.tidal_url,
tidal_variant: tidalVariant,
duration: durationSeconds, duration: durationSeconds,
item_id: itemID, item_id: itemID,
audio_format: tidalQuality, audio_format: tidalQuality,
@@ -532,17 +532,17 @@ export function useDownload(region: string) {
embed_genre: settings.embedGenre, embed_genre: settings.embedGenre,
}); });
if (response.success) { if (response.success) {
logger.success(`Tidal: ${trackName} - ${artistName}`); logger.success(`${tidalLabel}: ${trackName} - ${artistName}`);
return response; return response;
} }
const errMsg = response.error || response.message || "Failed"; const errMsg = response.error || response.message || "Failed";
fallbackErrors.push(`[Tidal] ${errMsg}`); fallbackErrors.push(`[${tidalLabel}] ${errMsg}`);
lastResponse = response; lastResponse = response;
logger.warning(`Tidal failed, trying next...`); logger.warning(`${tidalLabel} failed, trying next...`);
} }
catch (err) { catch (err) {
logger.error(`Tidal error: ${err}`); logger.error(`${tidalLabel} error: ${err}`);
fallbackErrors.push(`[Tidal] ${String(err)}`); fallbackErrors.push(`[${tidalLabel}] ${String(err)}`);
lastResponse = { success: false, error: String(err) }; lastResponse = { success: false, error: String(err) };
} }
} }
@@ -679,6 +679,7 @@ export function useDownload(region: string) {
duration: durationSecondsForFallback, duration: durationSecondsForFallback,
item_id: itemID, item_id: itemID,
audio_format: audioFormat, audio_format: audioFormat,
tidal_variant: service === "tidal" ? getTidalVariant(settings) : undefined,
spotify_track_number: spotifyTrackNumber, spotify_track_number: spotifyTrackNumber,
spotify_disc_number: spotifyDiscNumber, spotify_disc_number: spotifyDiscNumber,
spotify_total_tracks: spotifyTotalTracks, spotify_total_tracks: spotifyTotalTracks,
@@ -746,8 +747,6 @@ export function useDownload(region: string) {
setIsDownloading(true); setIsDownloading(true);
setBulkDownloadType("selected"); setBulkDownloadType("selected");
setDownloadProgress(0); setDownloadProgress(0);
setDownloadRemainingCount(selectedTracks.length);
setCurrentDownloadInfo(null);
let outputDir = settings.downloadPath; let outputDir = settings.downloadPath;
const os = settings.operatingSystem; const os = settings.operatingSystem;
const useAlbumTag = settings.folderTemplate?.includes("{album}"); const useAlbumTag = settings.folderTemplate?.includes("{album}");
@@ -816,7 +815,7 @@ export function useDownload(region: string) {
let errorCount = 0; let errorCount = 0;
let skippedCount = existingSpotifyIDs.size; let skippedCount = existingSpotifyIDs.size;
const total = selectedTracks.length; const total = selectedTracks.length;
updateBatchProgress(skippedCount, total); setDownloadProgress(Math.round((skippedCount / total) * 100));
for (let i = 0; i < tracksToDownload.length; i++) { for (let i = 0; i < tracksToDownload.length; i++) {
if (shouldStopDownloadRef.current) { if (shouldStopDownloadRef.current) {
toast.info(`Download stopped. ${successCount} tracks downloaded, ${tracksToDownload.length - i} remaining.`); toast.info(`Download stopped. ${successCount} tracks downloaded, ${tracksToDownload.length - i} remaining.`);
@@ -869,13 +868,12 @@ export function useDownload(region: string) {
} }
} }
const completedCount = skippedCount + successCount + errorCount; const completedCount = skippedCount + successCount + errorCount;
updateBatchProgress(completedCount, total); setDownloadProgress(Math.min(100, Math.round((completedCount / total) * 100)));
} }
setDownloadingTrack(null); setDownloadingTrack(null);
setCurrentDownloadInfo(null); setCurrentDownloadInfo(null);
setIsDownloading(false); setIsDownloading(false);
setBulkDownloadType(null); setBulkDownloadType(null);
updateBatchProgress(0, 0);
shouldStopDownloadRef.current = false; shouldStopDownloadRef.current = false;
const { CancelAllQueuedItems } = await import("../../wailsjs/go/main/App"); const { CancelAllQueuedItems } = await import("../../wailsjs/go/main/App");
await CancelAllQueuedItems(); await CancelAllQueuedItems();
@@ -924,8 +922,6 @@ export function useDownload(region: string) {
setIsDownloading(true); setIsDownloading(true);
setBulkDownloadType("all"); setBulkDownloadType("all");
setDownloadProgress(0); setDownloadProgress(0);
setDownloadRemainingCount(tracksWithId.length);
setCurrentDownloadInfo(null);
let outputDir = settings.downloadPath; let outputDir = settings.downloadPath;
const os = settings.operatingSystem; const os = settings.operatingSystem;
const useAlbumTag = settings.folderTemplate?.includes("{album}"); const useAlbumTag = settings.folderTemplate?.includes("{album}");
@@ -989,7 +985,7 @@ export function useDownload(region: string) {
let errorCount = 0; let errorCount = 0;
let skippedCount = existingSpotifyIDs.size; let skippedCount = existingSpotifyIDs.size;
const total = tracksWithId.length; const total = tracksWithId.length;
updateBatchProgress(skippedCount, total); setDownloadProgress(Math.round((skippedCount / total) * 100));
for (let i = 0; i < tracksToDownload.length; i++) { for (let i = 0; i < tracksToDownload.length; i++) {
if (shouldStopDownloadRef.current) { if (shouldStopDownloadRef.current) {
toast.info(`Download stopped. ${successCount} tracks downloaded, ${tracksToDownload.length - i} remaining.`); toast.info(`Download stopped. ${successCount} tracks downloaded, ${tracksToDownload.length - i} remaining.`);
@@ -1039,13 +1035,12 @@ export function useDownload(region: string) {
await MarkDownloadItemFailed(itemID, err instanceof Error ? err.message : String(err)); await MarkDownloadItemFailed(itemID, err instanceof Error ? err.message : String(err));
} }
const completedCount = skippedCount + successCount + errorCount; const completedCount = skippedCount + successCount + errorCount;
updateBatchProgress(completedCount, total); setDownloadProgress(Math.min(100, Math.round((completedCount / total) * 100)));
} }
setDownloadingTrack(null); setDownloadingTrack(null);
setCurrentDownloadInfo(null); setCurrentDownloadInfo(null);
setIsDownloading(false); setIsDownloading(false);
setBulkDownloadType(null); setBulkDownloadType(null);
updateBatchProgress(0, 0);
shouldStopDownloadRef.current = false; shouldStopDownloadRef.current = false;
const { CancelAllQueuedItems: CancelQueued } = await import("../../wailsjs/go/main/App"); const { CancelAllQueuedItems: CancelQueued } = await import("../../wailsjs/go/main/App");
await CancelQueued(); await CancelQueued();
@@ -1092,7 +1087,6 @@ export function useDownload(region: string) {
}; };
return { return {
downloadProgress, downloadProgress,
downloadRemainingCount,
isDownloading, isDownloading,
downloadingTrack, downloadingTrack,
bulkDownloadType, bulkDownloadType,
+1 -5
View File
@@ -9,17 +9,13 @@ const GetTrackISRC = (spotifyId: string): Promise<string> => (window as any)["go
async function resolveTemplateISRC(settings: { async function resolveTemplateISRC(settings: {
folderTemplate?: string; folderTemplate?: string;
filenameTemplate?: string; filenameTemplate?: string;
existingFileCheckMode?: string;
}, spotifyId?: string): Promise<string> { }, spotifyId?: string): Promise<string> {
if (!spotifyId) { if (!spotifyId) {
return ""; return "";
} }
const folderTemplate = settings.folderTemplate || ""; const folderTemplate = settings.folderTemplate || "";
const filenameTemplate = settings.filenameTemplate || ""; const filenameTemplate = settings.filenameTemplate || "";
const shouldResolveISRC = settings.existingFileCheckMode === "isrc" || if (!folderTemplate.includes("{isrc}") && !filenameTemplate.includes("{isrc}")) {
folderTemplate.includes("{isrc}") ||
filenameTemplate.includes("{isrc}");
if (!shouldResolveISRC) {
return ""; return "";
} }
try { try {
+26 -31
View File
@@ -1,34 +1,32 @@
import { useEffect, useRef, useState } from "react"; import { useState, useEffect } from "react";
import { GetPreviewURL } from "@/../wailsjs/go/main/App"; import { GetPreviewURL } from "@/../wailsjs/go/main/App";
import { getPreviewVolume } from "@/lib/preview"; import { SPOTIFY_PREVIEW_VOLUME } from "@/lib/preview";
import { createPreviewPlayback, type PreviewPlayback } from "@/lib/preview-player";
import { toast } from "sonner"; import { toast } from "sonner";
export function usePreview() { export function usePreview() {
const [loadingPreview, setLoadingPreview] = useState<string | null>(null); const [loadingPreview, setLoadingPreview] = useState<string | null>(null);
const [currentAudio, setCurrentAudio] = useState<HTMLAudioElement | null>(null);
const [playingTrack, setPlayingTrack] = useState<string | 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(() => { useEffect(() => {
return () => { return () => {
stopCurrentAudio(); if (currentAudio) {
currentAudio.pause();
currentAudio.currentTime = 0;
}
}; };
}, []); }, [currentAudio]);
const playPreview = async (trackId: string, trackName: string) => { const playPreview = async (trackId: string, trackName: string) => {
try { try {
const currentAudio = currentPlaybackRef.current?.audio;
if (playingTrack === trackId && currentAudio) { if (playingTrack === trackId && currentAudio) {
stopCurrentAudio(); currentAudio.pause();
currentAudio.currentTime = 0;
setPlayingTrack(null); setPlayingTrack(null);
setCurrentAudio(null);
return; return;
} }
if (currentAudio) { if (currentAudio) {
stopCurrentAudio(); currentAudio.pause();
currentAudio.currentTime = 0;
setCurrentAudio(null);
setPlayingTrack(null); setPlayingTrack(null);
} }
setLoadingPreview(trackId); setLoadingPreview(trackId);
@@ -40,18 +38,15 @@ export function usePreview() {
setLoadingPreview(null); setLoadingPreview(null);
return; return;
} }
const playback = await createPreviewPlayback(previewURL, getPreviewVolume()); const audio = new Audio(previewURL);
const audio = playback.audio; audio.volume = SPOTIFY_PREVIEW_VOLUME;
audio.addEventListener("loadeddata", () => { audio.addEventListener("loadeddata", () => {
setLoadingPreview(null); setLoadingPreview(null);
setPlayingTrack(trackId); setPlayingTrack(trackId);
}); });
audio.addEventListener("ended", () => { audio.addEventListener("ended", () => {
setPlayingTrack(null); setPlayingTrack(null);
if (currentPlaybackRef.current?.audio === audio) { setCurrentAudio(null);
currentPlaybackRef.current.destroy();
currentPlaybackRef.current = null;
}
}); });
audio.addEventListener("error", () => { audio.addEventListener("error", () => {
toast.error("Failed to play preview", { toast.error("Failed to play preview", {
@@ -59,27 +54,27 @@ export function usePreview() {
}); });
setLoadingPreview(null); setLoadingPreview(null);
setPlayingTrack(null); setPlayingTrack(null);
if (currentPlaybackRef.current?.audio === audio) { setCurrentAudio(null);
currentPlaybackRef.current.destroy();
currentPlaybackRef.current = null;
}
}); });
currentPlaybackRef.current = playback; setCurrentAudio(audio);
await audio.play(); await audio.play();
} }
catch (error: unknown) { catch (error: any) {
stopCurrentAudio();
console.error("Preview error:", error); console.error("Preview error:", error);
toast.error("Preview not available", { toast.error("Preview not available", {
description: error instanceof Error ? error.message : `Could not load preview for "${trackName}"`, description: error?.message || `Could not load preview for "${trackName}"`,
}); });
setLoadingPreview(null); setLoadingPreview(null);
setPlayingTrack(null); setPlayingTrack(null);
} }
}; };
const stopPreview = () => { const stopPreview = () => {
stopCurrentAudio(); if (currentAudio) {
currentAudio.pause();
currentAudio.currentTime = 0;
setCurrentAudio(null);
setPlayingTrack(null); setPlayingTrack(null);
}
}; };
return { return {
playPreview, playPreview,
+28 -29
View File
@@ -10,10 +10,19 @@ export interface ApiSource {
interface SpotiFLACNextSource { interface SpotiFLACNextSource {
id: string; id: string;
name: string; name: string;
statusKey?: string;
statusPrefix?: string;
} }
type SpotiFLACNextStatusResponse = Partial<Record<string, 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;
};
export const API_SOURCES: ApiSource[] = [ export const API_SOURCES: ApiSource[] = [
{ id: "tidal", type: "tidal", name: "Tidal", url: "" }, { id: "tidal", type: "tidal", name: "Tidal", url: "" },
{ id: "qobuz", type: "qobuz", name: "Qobuz", url: "" }, { id: "qobuz", type: "qobuz", name: "Qobuz", url: "" },
@@ -21,13 +30,13 @@ export const API_SOURCES: ApiSource[] = [
{ id: "musicbrainz", type: "musicbrainz", name: "MusicBrainz", url: "https://musicbrainz.org" }, { id: "musicbrainz", type: "musicbrainz", name: "MusicBrainz", url: "https://musicbrainz.org" },
]; ];
export const SPOTIFLAC_NEXT_SOURCES: SpotiFLACNextSource[] = [ export const SPOTIFLAC_NEXT_SOURCES: SpotiFLACNextSource[] = [
{ id: "tidal", name: "Tidal", statusKey: "tidal" }, { id: "tidal", name: "Tidal" },
{ id: "qobuz", name: "Qobuz", statusPrefix: "qobuz_" }, { id: "qobuz", name: "Qobuz" },
{ id: "amazon", name: "Amazon Music", statusPrefix: "amazon_" }, { id: "amazon", name: "Amazon Music" },
{ id: "deezer", name: "Deezer", statusPrefix: "deezer_" }, { id: "deezer", name: "Deezer" },
{ id: "apple", name: "Apple Music", statusKey: "apple" }, { id: "apple", name: "Apple Music" },
]; ];
const SPOTIFLAC_NEXT_STATUS_URL = "https://gist.githubusercontent.com/afkarxyz/6e57cd362cbd67f889e3a91a76254a5e/raw"; const SPOTIFLAC_NEXT_STATUS_URL = "https://status.spotbye.qzz.io/status";
const SPOTIFLAC_NEXT_MAX_ATTEMPTS = 3; const SPOTIFLAC_NEXT_MAX_ATTEMPTS = 3;
const SPOTIFLAC_NEXT_RETRY_DELAY_MS = 1200; const SPOTIFLAC_NEXT_RETRY_DELAY_MS = 1200;
type ApiStatusState = { type ApiStatusState = {
@@ -61,25 +70,12 @@ async function checkSourceStatus(source: ApiSource): Promise<ApiCheckStatus> {
return "offline"; return "offline";
} }
} }
function statusFromNextValue(value: string | undefined): ApiCheckStatus {
return value === "up" ? "online" : "offline";
}
function anyNextVariantUp(values: Array<string | undefined>): ApiCheckStatus { function anyNextVariantUp(values: Array<string | undefined>): ApiCheckStatus {
return values.some((value) => value === "up") ? "online" : "offline"; 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> { function delay(ms: number): Promise<void> {
return new Promise((resolve) => window.setTimeout(resolve, ms)); return new Promise((resolve) => window.setTimeout(resolve, ms));
} }
@@ -102,10 +98,13 @@ async function fetchSpotiFLACNextStatusesOnce(): Promise<Record<string, ApiCheck
throw new Error(`SpotiFLAC Next status returned ${response.status}`); throw new Error(`SpotiFLAC Next status returned ${response.status}`);
} }
const payload = (await response.json()) as SpotiFLACNextStatusResponse; const payload = (await response.json()) as SpotiFLACNextStatusResponse;
return SPOTIFLAC_NEXT_SOURCES.reduce<Record<string, ApiCheckStatus>>((acc, source) => { return {
acc[source.id] = anyNextVariantUp(getNextSourceValues(payload, source)); tidal: statusFromNextValue(payload.tidal),
return acc; qobuz: anyNextVariantUp([payload.qobuz_a, payload.qobuz_b, payload.qobuz_c]),
}, {}); deezer: anyNextVariantUp([payload.deezer_a, payload.deezer_b]),
amazon: anyNextVariantUp([payload.amazon_a, payload.amazon_b, payload.amazon_c]),
apple: statusFromNextValue(payload.apple),
};
} }
async function checkSpotiFLACNextStatuses(): Promise<Record<string, ApiCheckStatus>> { async function checkSpotiFLACNextStatuses(): Promise<Record<string, ApiCheckStatus>> {
let lastError: unknown = null; let lastError: unknown = null;
+3
View File
@@ -13,6 +13,9 @@ export async function fetchSpotifyMetadata(url: string, batch: boolean = true, d
} }
export async function downloadTrack(request: DownloadRequest): Promise<DownloadResponse> { export async function downloadTrack(request: DownloadRequest): Promise<DownloadResponse> {
const req = new main.DownloadRequest(request); const req = new main.DownloadRequest(request);
if (request.tidal_variant !== undefined) {
(req as any).tidal_variant = request.tidal_variant;
}
if (request.use_single_genre !== undefined) { if (request.use_single_genre !== undefined) {
(req as any).use_single_genre = request.use_single_genre; (req as any).use_single_genre = request.use_single_genre;
} }
-37
View File
@@ -1,37 +0,0 @@
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,10 +1 @@
import { getSettings } from "@/lib/settings";
export const SPOTIFY_PREVIEW_VOLUME = 1; 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));
}
+236 -574
View File
@@ -1,32 +1,15 @@
import { GetDefaults, LoadFonts as LoadFontsFromBackend, LoadSettings, SaveFonts as SaveFontsToBackend, SaveSettings as SaveToBackend, } from "../../wailsjs/go/main/App"; import { GetDefaults, LoadSettings, 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 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";
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 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 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 { export interface Settings {
downloadPath: string; downloadPath: string;
downloader: "auto" | "tidal" | "qobuz" | "amazon"; downloader: "auto" | "tidal" | "qobuz" | "amazon";
customTidalApi: string;
linkResolver: "songstats" | "songlink"; linkResolver: "songstats" | "songlink";
allowResolverFallback: boolean; allowResolverFallback: boolean;
theme: string; theme: string;
themeMode: "auto" | "light" | "dark"; themeMode: "auto" | "light" | "dark";
fontFamily: FontFamily; fontFamily: FontFamily;
customFonts: CustomFontOption[];
folderPreset: FolderPreset; folderPreset: FolderPreset;
folderTemplate: string; folderTemplate: string;
filenamePreset: FilenamePreset; filenamePreset: FilenamePreset;
@@ -39,6 +22,7 @@ export interface Settings {
embedLyrics: boolean; embedLyrics: boolean;
embedMaxQualityCover: boolean; embedMaxQualityCover: boolean;
operatingSystem: "Windows" | "linux/MacOS"; operatingSystem: "Windows" | "linux/MacOS";
tidalVariant: "tidal" | "alt";
tidalQuality: "LOSSLESS" | "HI_RES_LOSSLESS"; tidalQuality: "LOSSLESS" | "HI_RES_LOSSLESS";
qobuzQuality: "6" | "7" | "27"; qobuzQuality: "6" | "7" | "27";
amazonQuality: "original"; amazonQuality: "original";
@@ -48,8 +32,6 @@ export interface Settings {
createPlaylistFolder: boolean; createPlaylistFolder: boolean;
playlistOwnerFolderName: boolean; playlistOwnerFolderName: boolean;
createM3u8File: boolean; createM3u8File: boolean;
previewVolume: number;
existingFileCheckMode: ExistingFileCheckMode;
useFirstArtistOnly: boolean; useFirstArtistOnly: boolean;
useSingleGenre: boolean; useSingleGenre: boolean;
embedGenre: boolean; embedGenre: boolean;
@@ -60,105 +42,54 @@ export const FOLDER_PRESETS: Record<FolderPreset, {
label: string; label: string;
template: string; template: string;
}> = { }> = {
none: { label: "No Subfolder", template: "" }, "none": { label: "No Subfolder", template: "" },
artist: { label: "Artist", template: "{artist}" }, "artist": { label: "Artist", template: "{artist}" },
album: { label: "Album", template: "{album}" }, "album": { label: "Album", template: "{album}" },
"year-album": { label: "[Year] Album", template: "[{year}] {album}" }, "year-album": { label: "[Year] Album", template: "[{year}] {album}" },
"year-artist-album": { "year-artist-album": { label: "[Year] Artist - Album", template: "[{year}] {artist} - {album}" },
label: "[Year] Artist - Album",
template: "[{year}] {artist} - {album}",
},
"artist-album": { label: "Artist / Album", template: "{artist}/{album}" }, "artist-album": { label: "Artist / Album", template: "{artist}/{album}" },
"artist-year-album": { "artist-year-album": { label: "Artist / [Year] Album", template: "{artist}/[{year}] {album}" },
label: "Artist / [Year] Album", "artist-year-nested-album": { label: "Artist / Year / Album", template: "{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": { label: "Album Artist", template: "{album_artist}" },
"album-artist-album": { "album-artist-album": { label: "Album Artist / Album", template: "{album_artist}/{album}" },
label: "Album Artist / Album", "album-artist-year-album": { label: "Album Artist / [Year] Album", template: "{album_artist}/[{year}] {album}" },
template: "{album_artist}/{album}", "album-artist-year-nested-album": { label: "Album Artist / Year / Album", template: "{album_artist}/{year}/{album}" },
}, "year": { label: "Year", template: "{year}" },
"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}" }, "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, { export const FILENAME_PRESETS: Record<FilenamePreset, {
label: string; label: string;
template: string; template: string;
}> = { }> = {
title: { label: "Title", template: "{title}" }, "title": { label: "Title", template: "{title}" },
"title-artist": { label: "Title - Artist", template: "{title} - {artist}" }, "title-artist": { label: "Title - Artist", template: "{title} - {artist}" },
"artist-title": { label: "Artist - Title", template: "{artist} - {title}" }, "artist-title": { label: "Artist - Title", template: "{artist} - {title}" },
"track-title": { label: "Track. Title", template: "{track}. {title}" }, "track-title": { label: "Track. Title", template: "{track}. {title}" },
"track-title-artist": { "track-title-artist": { label: "Track. Title - Artist", template: "{track}. {title} - {artist}" },
label: "Track. Title - Artist", "track-artist-title": { label: "Track. Artist - Title", template: "{track}. {artist} - {title}" },
template: "{track}. {title} - {artist}", "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}" },
"track-artist-title": { "artist-album-title": { label: "Artist - Album - Title", template: "{artist} - {album} - {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}" }, "track-dash-title": { label: "Track - Title", template: "{track} - {title}" },
"disc-track-title": { "disc-track-title": { label: "Disc-Track. Title", template: "{disc}-{track}. {title}" },
label: "Disc-Track. Title", "disc-track-title-artist": { label: "Disc-Track. Title - Artist", template: "{disc}-{track}. {title} - {artist}" },
template: "{disc}-{track}. {title}", "custom": { label: "Custom...", template: "{title} - {artist}" },
},
"disc-track-title-artist": {
label: "Disc-Track. Title - Artist",
template: "{disc}-{track}. {title} - {artist}",
},
custom: { label: "Custom...", template: "{title} - {artist}" },
}; };
export const TEMPLATE_VARIABLES = [ export const TEMPLATE_VARIABLES = [
{ key: "{title}", description: "Track title", example: "Shake It Off" }, { key: "{title}", description: "Track title", example: "Shake It Off" },
{ key: "{artist}", description: "Track artist", example: "Taylor Swift" }, { key: "{artist}", description: "Track artist", example: "Taylor Swift" },
{ key: "{album}", description: "Album name", example: "1989" }, { 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: "{track}", description: "Track number", example: "01" },
{ key: "{disc}", description: "Disc number", example: "1" }, { key: "{disc}", description: "Disc number", example: "1" },
{ key: "{year}", description: "Release year", example: "2014" }, { key: "{year}", description: "Release year", example: "2014" },
{ { key: "{date}", description: "Release date (YYYY-MM-DD)", example: "2014-10-27" },
key: "{date}", { key: "{isrc}", description: "Track ISRC", example: "USUM71412345" },
description: "Release date (YYYY-MM-DD)",
example: "2014-10-27",
},
{
key: "{isrc}",
description: "Track ISRC",
example: "USUM71412345",
},
]; ];
function detectOS(): "Windows" | "linux/MacOS" { function detectOS(): "Windows" | "linux/MacOS" {
const platform = window.navigator.platform.toLowerCase(); const platform = window.navigator.platform.toLowerCase();
if (platform.includes("win")) { if (platform.includes('win')) {
return "Windows"; return "Windows";
} }
return "linux/MacOS"; return "linux/MacOS";
@@ -166,13 +97,11 @@ function detectOS(): "Windows" | "linux/MacOS" {
export const DEFAULT_SETTINGS: Settings = { export const DEFAULT_SETTINGS: Settings = {
downloadPath: "", downloadPath: "",
downloader: "auto", downloader: "auto",
customTidalApi: "",
linkResolver: "songlink", linkResolver: "songlink",
allowResolverFallback: true, allowResolverFallback: true,
theme: "yellow", theme: "yellow",
themeMode: "auto", themeMode: "auto",
fontFamily: "google-sans", fontFamily: "google-sans",
customFonts: [],
folderPreset: "none", folderPreset: "none",
folderTemplate: "", folderTemplate: "",
filenamePreset: "title-artist", filenamePreset: "title-artist",
@@ -182,6 +111,7 @@ export const DEFAULT_SETTINGS: Settings = {
embedLyrics: false, embedLyrics: false,
embedMaxQualityCover: false, embedMaxQualityCover: false,
operatingSystem: detectOS(), operatingSystem: detectOS(),
tidalVariant: "tidal",
tidalQuality: "LOSSLESS", tidalQuality: "LOSSLESS",
qobuzQuality: "6", qobuzQuality: "6",
amazonQuality: "original", amazonQuality: "original",
@@ -191,461 +121,42 @@ export const DEFAULT_SETTINGS: Settings = {
createPlaylistFolder: true, createPlaylistFolder: true,
playlistOwnerFolderName: false, playlistOwnerFolderName: false,
createM3u8File: false, createM3u8File: false,
previewVolume: 100,
existingFileCheckMode: "filename",
useFirstArtistOnly: false, useFirstArtistOnly: false,
useSingleGenre: false, useSingleGenre: false,
embedGenre: false, embedGenre: false,
redownloadWithSuffix: false, redownloadWithSuffix: false,
separator: "semicolon", separator: "semicolon"
}; };
export const FONT_OPTIONS: FontOption[] = [ export const FONT_OPTIONS: {
{ value: FontFamily;
value: "bricolage-grotesque", label: string;
label: "Bricolage Grotesque", fontFamily: string;
fontFamily: '"Bricolage Grotesque", system-ui, sans-serif', }[] = [
}, { 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: "dm-sans", { value: "figtree", label: "Figtree", fontFamily: '"Figtree", system-ui, sans-serif' },
label: "DM Sans", { value: "geist-sans", label: "Geist Sans", fontFamily: '"Geist", system-ui, sans-serif' },
fontFamily: '"DM Sans", 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: "figtree", { value: "manrope", label: "Manrope", fontFamily: '"Manrope", system-ui, sans-serif' },
label: "Figtree", { value: "noto-sans", label: "Noto Sans", fontFamily: '"Noto Sans", system-ui, sans-serif' },
fontFamily: '"Figtree", 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: "geist-sans", { value: "poppins", label: "Poppins", fontFamily: '"Poppins", system-ui, sans-serif' },
label: "Geist Sans", { value: "public-sans", label: "Public Sans", fontFamily: '"Public Sans", system-ui, sans-serif' },
fontFamily: '"Geist", 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' },
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',
},
]; ];
const BUILT_IN_FONT_VALUES = new Set(FONT_OPTIONS.map((font) => font.value)); export function applyFont(fontFamily: FontFamily): void {
const GOOGLE_FONT_LINK_ID_PREFIX = "spotiflac-custom-font-"; const font = FONT_OPTIONS.find(f => f.value === fontFamily);
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) { if (font) {
document.documentElement.style.setProperty("--font-sans", font.fontFamily); document.documentElement.style.setProperty('--font-sans', font.fontFamily);
document.body.style.fontFamily = 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> { async function fetchDefaultPath(): Promise<string> {
try { try {
const data = await GetDefaults(); const data = await GetDefaults();
@@ -656,11 +167,90 @@ async function fetchDefaultPath(): Promise<string> {
return ""; return "";
} }
} }
const SETTINGS_KEY = "spotiflac-settings";
let cachedSettings: Settings | null = null;
function getSettingsFromLocalStorage(): Settings { function getSettingsFromLocalStorage(): Settings {
try { try {
const stored = localStorage.getItem(SETTINGS_KEY); const stored = localStorage.getItem(SETTINGS_KEY);
if (stored) { if (stored) {
return toNormalizedSettings(JSON.parse(stored) as SettingsPayload); 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 };
} }
} }
catch (error) { catch (error) {
@@ -669,25 +259,108 @@ function getSettingsFromLocalStorage(): Settings {
return DEFAULT_SETTINGS; return DEFAULT_SETTINGS;
} }
export function getSettings(): Settings { export function getSettings(): Settings {
if (cachedSettings) { if (cachedSettings)
return cachedSettings; return cachedSettings;
}
return getSettingsFromLocalStorage(); return getSettingsFromLocalStorage();
} }
export async function loadSettings(): Promise<Settings> { export async function loadSettings(): Promise<Settings> {
try { try {
const backendSettings = await LoadSettings(); const backendSettings = await LoadSettings();
if (backendSettings) { if (backendSettings) {
const parsed = backendSettings as SettingsPayload; const parsed = backendSettings as any;
const customFonts = await loadStoredCustomFonts(parsed.customFonts); if ('darkMode' in parsed && !('themeMode' in parsed)) {
cachedSettings = toNormalizedSettings({ parsed.themeMode = parsed.darkMode ? 'dark' : 'light';
...parsed, delete parsed.darkMode;
customFonts,
});
if ("customFonts" in parsed) {
await persistSettingsInternal(cachedSettings, false);
} }
return cachedSettings; 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!;
} }
} }
catch (error) { catch (error) {
@@ -695,19 +368,12 @@ export async function loadSettings(): Promise<Settings> {
} }
const local = getSettingsFromLocalStorage(); const local = getSettingsFromLocalStorage();
try { try {
const customFonts = await loadStoredCustomFonts(local.customFonts); await SaveToBackend(local as any);
const localWithFonts = toNormalizedSettings({ cachedSettings = local;
...local,
customFonts,
});
await persistSettingsInternal(localWithFonts, false);
cachedSettings = localWithFonts;
return localWithFonts;
} }
catch (error) { catch (error) {
console.error("Failed to migrate settings to backend:", error); console.error("Failed to migrate settings to backend:", error);
} }
cachedSettings = local;
return local; return local;
} }
export interface TemplateData { export interface TemplateData {
@@ -723,9 +389,8 @@ export interface TemplateData {
playlist?: string; playlist?: string;
} }
export function parseTemplate(template: string, data: TemplateData): string { export function parseTemplate(template: string, data: TemplateData): string {
if (!template) { if (!template)
return ""; return "";
}
let result = template; let result = template;
result = result.replace(/\{title\}/g, data.title || "Unknown Title"); result = result.replace(/\{title\}/g, data.title || "Unknown Title");
result = result.replace(/\{artist\}/g, data.artist || "Unknown Artist"); result = result.replace(/\{artist\}/g, data.artist || "Unknown Artist");
@@ -749,8 +414,10 @@ export async function getSettingsWithDefaults(): Promise<Settings> {
} }
export async function saveSettings(settings: Settings): Promise<void> { export async function saveSettings(settings: Settings): Promise<void> {
try { try {
const normalizedSettings = toNormalizedSettings(settings as SettingsPayload); cachedSettings = settings;
await persistSettingsInternal(normalizedSettings); localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
await SaveToBackend(settings as any);
window.dispatchEvent(new CustomEvent('settingsUpdated', { detail: settings }));
} }
catch (error) { catch (error) {
console.error("Failed to save settings:", error); console.error("Failed to save settings:", error);
@@ -764,12 +431,7 @@ export async function updateSettings(partial: Partial<Settings>): Promise<Settin
} }
export async function resetToDefaultSettings(): Promise<Settings> { export async function resetToDefaultSettings(): Promise<Settings> {
const defaultPath = await fetchDefaultPath(); const defaultPath = await fetchDefaultPath();
const customFonts = await loadCustomFonts(); const defaultSettings = { ...DEFAULT_SETTINGS, downloadPath: defaultPath };
const defaultSettings = {
...DEFAULT_SETTINGS,
downloadPath: defaultPath,
customFonts,
};
await saveSettings(defaultSettings); await saveSettings(defaultSettings);
return defaultSettings; return defaultSettings;
} }
+1
View File
@@ -120,6 +120,7 @@ export interface DownloadRequest {
release_date?: string; release_date?: string;
cover_url?: string; cover_url?: string;
tidal_api_url?: string; tidal_api_url?: string;
tidal_variant?: "tidal" | "alt";
output_dir?: string; output_dir?: string;
audio_format?: string; audio_format?: string;
folder_name?: string; folder_name?: string;
+142
View File
@@ -0,0 +1,142 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
import {backend} from '../models';
import {main} from '../models';
export function AddFetchHistory(arg1:backend.FetchHistoryItem):Promise<void>;
export function AddToDownloadQueue(arg1:string,arg2:string,arg3:string,arg4:string):Promise<string>;
export function CancelAllQueuedItems():Promise<void>;
export function CheckAPIStatus(arg1:string,arg2:string):Promise<boolean>;
export function CheckFFmpegInstalled():Promise<boolean>;
export function CheckFilesExistence(arg1:string,arg2:string,arg3:Array<main.CheckFileExistenceRequest>):Promise<Array<main.CheckFileExistenceResult>>;
export function CheckTrackAvailability(arg1:string):Promise<string>;
export function ClearAllDownloads():Promise<void>;
export function ClearCompletedDownloads():Promise<void>;
export function ClearDownloadHistory():Promise<void>;
export function ClearFetchHistory():Promise<void>;
export function ClearFetchHistoryByType(arg1:string):Promise<void>;
export function ConvertAudio(arg1:main.ConvertAudioRequest):Promise<Array<backend.ConvertAudioResult>>;
export function CreateM3U8File(arg1:string,arg2:string,arg3:Array<string>):Promise<void>;
export function DecodeAudioForAnalysis(arg1:string):Promise<backend.AnalysisDecodeResponse>;
export function DeleteDownloadHistoryItem(arg1:string):Promise<void>;
export function DeleteFetchHistoryItem(arg1:string):Promise<void>;
export function DownloadAvatar(arg1:main.AvatarDownloadRequest):Promise<backend.AvatarDownloadResponse>;
export function DownloadCover(arg1:main.CoverDownloadRequest):Promise<backend.CoverDownloadResponse>;
export function DownloadFFmpeg():Promise<main.DownloadFFmpegResponse>;
export function DownloadGalleryImage(arg1:main.GalleryImageDownloadRequest):Promise<backend.GalleryImageDownloadResponse>;
export function DownloadHeader(arg1:main.HeaderDownloadRequest):Promise<backend.HeaderDownloadResponse>;
export function DownloadLyrics(arg1:main.LyricsDownloadRequest):Promise<backend.LyricsDownloadResponse>;
export function DownloadTrack(arg1:main.DownloadRequest):Promise<main.DownloadResponse>;
export function ExportFailedDownloads():Promise<string>;
export function GetBrewPath():Promise<string>;
export function GetConfigPath():Promise<string>;
export function GetCurrentIPInfo():Promise<string>;
export function GetDefaults():Promise<Record<string, string>>;
export function GetDownloadHistory():Promise<Array<backend.HistoryItem>>;
export function GetDownloadProgress():Promise<backend.ProgressInfo>;
export function GetDownloadQueue():Promise<backend.DownloadQueueInfo>;
export function GetFetchHistory():Promise<Array<backend.FetchHistoryItem>>;
export function GetFileSizes(arg1:Array<string>):Promise<Record<string, number>>;
export function GetFlacInfoBatch(arg1:Array<string>):Promise<Array<backend.FlacInfo>>;
export function GetPreviewURL(arg1:string):Promise<string>;
export function GetRecentFetches():Promise<string>;
export function GetSpotifyMetadata(arg1:main.SpotifyMetadataRequest):Promise<string>;
export function GetStreamingURLs(arg1:string,arg2:string):Promise<string>;
export function GetTrackISRC(arg1:string):Promise<string>;
export function InstallFFmpegWithBrew():Promise<main.InstallFFmpegWithBrewResponse>;
export function IsBrewFFmpegInstalled():Promise<boolean>;
export function IsFFmpegInstalled():Promise<boolean>;
export function IsFFprobeInstalled():Promise<boolean>;
export function ListAudioFilesInDir(arg1:string):Promise<Array<backend.FileInfo>>;
export function ListDirectoryFiles(arg1:string):Promise<Array<backend.FileInfo>>;
export function LoadSettings():Promise<Record<string, any>>;
export function MarkDownloadItemFailed(arg1:string,arg2:string):Promise<void>;
export function OpenConfigFolder():Promise<void>;
export function OpenFolder(arg1:string):Promise<void>;
export function PreviewRenameFiles(arg1:Array<string>,arg2:string):Promise<Array<backend.RenamePreview>>;
export function Quit():Promise<void>;
export function ReadFileAsBase64(arg1:string):Promise<string>;
export function ReadFileMetadata(arg1:string):Promise<backend.AudioMetadata>;
export function ReadImageAsBase64(arg1:string):Promise<string>;
export function ReadTextFile(arg1:string):Promise<string>;
export function RenameFileTo(arg1:string,arg2:string):Promise<void>;
export function RenameFilesByMetadata(arg1:Array<string>,arg2:string):Promise<Array<backend.RenameResult>>;
export function ResampleAudio(arg1:main.ResampleAudioRequest):Promise<Array<backend.ResampleResult>>;
export function SaveRecentFetches(arg1:string):Promise<void>;
export function SaveSettings(arg1:Record<string, any>):Promise<void>;
export function SaveSpectrumImage(arg1:string,arg2:string):Promise<string>;
export function SearchSpotify(arg1:main.SpotifySearchRequest):Promise<backend.SearchResponse>;
export function SearchSpotifyByType(arg1:main.SpotifySearchByTypeRequest):Promise<Array<backend.SearchResult>>;
export function SelectAudioFiles():Promise<Array<string>>;
export function SelectFile():Promise<string>;
export function SelectFolder(arg1:string):Promise<string>;
export function SelectImageVideo():Promise<Array<string>>;
export function SkipDownloadItem(arg1:string,arg2:string):Promise<void>;
+279
View File
@@ -0,0 +1,279 @@
// @ts-check
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
export function AddFetchHistory(arg1) {
return window['go']['main']['App']['AddFetchHistory'](arg1);
}
export function AddToDownloadQueue(arg1, arg2, arg3, arg4) {
return window['go']['main']['App']['AddToDownloadQueue'](arg1, arg2, arg3, arg4);
}
export function CancelAllQueuedItems() {
return window['go']['main']['App']['CancelAllQueuedItems']();
}
export function CheckAPIStatus(arg1, arg2) {
return window['go']['main']['App']['CheckAPIStatus'](arg1, arg2);
}
export function CheckFFmpegInstalled() {
return window['go']['main']['App']['CheckFFmpegInstalled']();
}
export function CheckFilesExistence(arg1, arg2, arg3) {
return window['go']['main']['App']['CheckFilesExistence'](arg1, arg2, arg3);
}
export function CheckTrackAvailability(arg1) {
return window['go']['main']['App']['CheckTrackAvailability'](arg1);
}
export function ClearAllDownloads() {
return window['go']['main']['App']['ClearAllDownloads']();
}
export function ClearCompletedDownloads() {
return window['go']['main']['App']['ClearCompletedDownloads']();
}
export function ClearDownloadHistory() {
return window['go']['main']['App']['ClearDownloadHistory']();
}
export function ClearFetchHistory() {
return window['go']['main']['App']['ClearFetchHistory']();
}
export function ClearFetchHistoryByType(arg1) {
return window['go']['main']['App']['ClearFetchHistoryByType'](arg1);
}
export function ConvertAudio(arg1) {
return window['go']['main']['App']['ConvertAudio'](arg1);
}
export function CreateM3U8File(arg1, arg2, arg3) {
return window['go']['main']['App']['CreateM3U8File'](arg1, arg2, arg3);
}
export function DecodeAudioForAnalysis(arg1) {
return window['go']['main']['App']['DecodeAudioForAnalysis'](arg1);
}
export function DeleteDownloadHistoryItem(arg1) {
return window['go']['main']['App']['DeleteDownloadHistoryItem'](arg1);
}
export function DeleteFetchHistoryItem(arg1) {
return window['go']['main']['App']['DeleteFetchHistoryItem'](arg1);
}
export function DownloadAvatar(arg1) {
return window['go']['main']['App']['DownloadAvatar'](arg1);
}
export function DownloadCover(arg1) {
return window['go']['main']['App']['DownloadCover'](arg1);
}
export function DownloadFFmpeg() {
return window['go']['main']['App']['DownloadFFmpeg']();
}
export function DownloadGalleryImage(arg1) {
return window['go']['main']['App']['DownloadGalleryImage'](arg1);
}
export function DownloadHeader(arg1) {
return window['go']['main']['App']['DownloadHeader'](arg1);
}
export function DownloadLyrics(arg1) {
return window['go']['main']['App']['DownloadLyrics'](arg1);
}
export function DownloadTrack(arg1) {
return window['go']['main']['App']['DownloadTrack'](arg1);
}
export function ExportFailedDownloads() {
return window['go']['main']['App']['ExportFailedDownloads']();
}
export function GetBrewPath() {
return window['go']['main']['App']['GetBrewPath']();
}
export function GetConfigPath() {
return window['go']['main']['App']['GetConfigPath']();
}
export function GetCurrentIPInfo() {
return window['go']['main']['App']['GetCurrentIPInfo']();
}
export function GetDefaults() {
return window['go']['main']['App']['GetDefaults']();
}
export function GetDownloadHistory() {
return window['go']['main']['App']['GetDownloadHistory']();
}
export function GetDownloadProgress() {
return window['go']['main']['App']['GetDownloadProgress']();
}
export function GetDownloadQueue() {
return window['go']['main']['App']['GetDownloadQueue']();
}
export function GetFetchHistory() {
return window['go']['main']['App']['GetFetchHistory']();
}
export function GetFileSizes(arg1) {
return window['go']['main']['App']['GetFileSizes'](arg1);
}
export function GetFlacInfoBatch(arg1) {
return window['go']['main']['App']['GetFlacInfoBatch'](arg1);
}
export function GetPreviewURL(arg1) {
return window['go']['main']['App']['GetPreviewURL'](arg1);
}
export function GetRecentFetches() {
return window['go']['main']['App']['GetRecentFetches']();
}
export function GetSpotifyMetadata(arg1) {
return window['go']['main']['App']['GetSpotifyMetadata'](arg1);
}
export function GetStreamingURLs(arg1, arg2) {
return window['go']['main']['App']['GetStreamingURLs'](arg1, arg2);
}
export function GetTrackISRC(arg1) {
return window['go']['main']['App']['GetTrackISRC'](arg1);
}
export function InstallFFmpegWithBrew() {
return window['go']['main']['App']['InstallFFmpegWithBrew']();
}
export function IsBrewFFmpegInstalled() {
return window['go']['main']['App']['IsBrewFFmpegInstalled']();
}
export function IsFFmpegInstalled() {
return window['go']['main']['App']['IsFFmpegInstalled']();
}
export function IsFFprobeInstalled() {
return window['go']['main']['App']['IsFFprobeInstalled']();
}
export function ListAudioFilesInDir(arg1) {
return window['go']['main']['App']['ListAudioFilesInDir'](arg1);
}
export function ListDirectoryFiles(arg1) {
return window['go']['main']['App']['ListDirectoryFiles'](arg1);
}
export function LoadSettings() {
return window['go']['main']['App']['LoadSettings']();
}
export function MarkDownloadItemFailed(arg1, arg2) {
return window['go']['main']['App']['MarkDownloadItemFailed'](arg1, arg2);
}
export function OpenConfigFolder() {
return window['go']['main']['App']['OpenConfigFolder']();
}
export function OpenFolder(arg1) {
return window['go']['main']['App']['OpenFolder'](arg1);
}
export function PreviewRenameFiles(arg1, arg2) {
return window['go']['main']['App']['PreviewRenameFiles'](arg1, arg2);
}
export function Quit() {
return window['go']['main']['App']['Quit']();
}
export function ReadFileAsBase64(arg1) {
return window['go']['main']['App']['ReadFileAsBase64'](arg1);
}
export function ReadFileMetadata(arg1) {
return window['go']['main']['App']['ReadFileMetadata'](arg1);
}
export function ReadImageAsBase64(arg1) {
return window['go']['main']['App']['ReadImageAsBase64'](arg1);
}
export function ReadTextFile(arg1) {
return window['go']['main']['App']['ReadTextFile'](arg1);
}
export function RenameFileTo(arg1, arg2) {
return window['go']['main']['App']['RenameFileTo'](arg1, arg2);
}
export function RenameFilesByMetadata(arg1, arg2) {
return window['go']['main']['App']['RenameFilesByMetadata'](arg1, arg2);
}
export function ResampleAudio(arg1) {
return window['go']['main']['App']['ResampleAudio'](arg1);
}
export function SaveRecentFetches(arg1) {
return window['go']['main']['App']['SaveRecentFetches'](arg1);
}
export function SaveSettings(arg1) {
return window['go']['main']['App']['SaveSettings'](arg1);
}
export function SaveSpectrumImage(arg1, arg2) {
return window['go']['main']['App']['SaveSpectrumImage'](arg1, arg2);
}
export function SearchSpotify(arg1) {
return window['go']['main']['App']['SearchSpotify'](arg1);
}
export function SearchSpotifyByType(arg1) {
return window['go']['main']['App']['SearchSpotifyByType'](arg1);
}
export function SelectAudioFiles() {
return window['go']['main']['App']['SelectAudioFiles']();
}
export function SelectFile() {
return window['go']['main']['App']['SelectFile']();
}
export function SelectFolder(arg1) {
return window['go']['main']['App']['SelectFolder'](arg1);
}
export function SelectImageVideo() {
return window['go']['main']['App']['SelectImageVideo']();
}
export function SkipDownloadItem(arg1, arg2) {
return window['go']['main']['App']['SkipDownloadItem'](arg1, arg2);
}
+1 -1
View File
@@ -12,7 +12,7 @@
}, },
"info": { "info": {
"productName": "SpotiFLAC", "productName": "SpotiFLAC",
"productVersion": "7.1.6", "productVersion": "7.1.5",
"copyright": "© 2026 afkarxyz" "copyright": "© 2026 afkarxyz"
}, },
"wailsjsdir": "./frontend", "wailsjsdir": "./frontend",