v7.1.6
This commit is contained in:
@@ -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)
|
[MusicBrainz](https://musicbrainz.org) · [LRCLIB](https://lrclib.net) · [Songlink/Odesli](https://song.link) · [hifi-api](https://github.com/binimum/hifi-api) · [dabmusic.xyz](https://dabmusic.xyz) · [musicdl.me](https://musicdl.me)
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -307,7 +307,6 @@ 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"`
|
||||||
@@ -508,7 +507,8 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
|||||||
if req.FilenameFormat == "" {
|
if req.FilenameFormat == "" {
|
||||||
req.FilenameFormat = "title-artist"
|
req.FilenameFormat = "title-artist"
|
||||||
}
|
}
|
||||||
if req.ISRC == "" && strings.Contains(req.FilenameFormat, "{isrc}") && req.SpotifyID != "" {
|
shouldResolveISRC := strings.Contains(req.FilenameFormat, "{isrc}") || backend.GetExistingFileCheckModeSetting() == "isrc"
|
||||||
|
if req.ISRC == "" && shouldResolveISRC && req.SpotifyID != "" {
|
||||||
req.ISRC = backend.ResolveTrackISRC(req.SpotifyID)
|
req.ISRC = backend.ResolveTrackISRC(req.SpotifyID)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -662,11 +662,7 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "tidal":
|
case "tidal":
|
||||||
tidalVariant := strings.ToLower(strings.TrimSpace(req.TidalVariant))
|
if req.TidalAPIURL == "" || req.TidalAPIURL == "auto" {
|
||||||
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)
|
||||||
@@ -795,9 +791,6 @@ 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)
|
||||||
@@ -826,21 +819,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,
|
||||||
}
|
}
|
||||||
|
|
||||||
if item.Format == "" || item.Format == "LOSSLESS" {
|
item.Format = strings.ToUpper(strings.TrimSpace(format))
|
||||||
ext := filepath.Ext(fPath)
|
|
||||||
if len(ext) > 1 {
|
if ext := filepath.Ext(fPath); 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":
|
case "6", "7", "27", "LOSSLESS", "HI_RES", "HI_RES_LOSSLESS":
|
||||||
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")
|
||||||
@@ -1029,6 +1022,90 @@ 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 != "" {
|
||||||
@@ -1058,18 +1135,18 @@ func buildQobuzStatusCheckURLs(apiURL string) []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bases := backend.GetQobuzStreamAPIBaseURLs()
|
bases := backend.GetQobuzStreamAPIBaseURLs()
|
||||||
urls := make([]string, 0, len(bases))
|
urls := make([]string, 0, len(bases)+1)
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1138,6 +1215,10 @@ 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
|
||||||
@@ -1733,6 +1814,68 @@ 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{}
|
||||||
@@ -1745,6 +1888,11 @@ 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
|
||||||
@@ -1752,29 +1900,13 @@ 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 rootDirFiles map[string]string
|
var lookupIndexOnce sync.Once
|
||||||
rootDirFilesOnce := false
|
getLookupIndex := func() existingFileLookupIndex {
|
||||||
getRootDirFiles := func() map[string]string {
|
lookupIndexOnce.Do(func() {
|
||||||
if rootDirFilesOnce {
|
lookupIndex = buildExistingFileLookupIndex(scanRoot, existingFileCheckMode)
|
||||||
return rootDirFiles
|
})
|
||||||
}
|
return lookupIndex
|
||||||
rootDirFiles = make(map[string]string)
|
|
||||||
if rootDir != "" && rootDir != outputDir {
|
|
||||||
filepath.Walk(rootDir, func(path string, info os.FileInfo, err error) error {
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if !info.IsDir() {
|
|
||||||
if strings.EqualFold(filepath.Ext(path), ".flac") || strings.EqualFold(filepath.Ext(path), ".mp3") {
|
|
||||||
rootDirFiles[info.Name()] = path
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
rootDirFilesOnce = true
|
|
||||||
return rootDirFiles
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for i, track := range tracks {
|
for i, track := range tracks {
|
||||||
@@ -1796,7 +1928,8 @@ func (a *App) CheckFilesExistence(outputDir string, rootDir string, tracks []Che
|
|||||||
filenameFormat = defaultFilenameFormat
|
filenameFormat = defaultFilenameFormat
|
||||||
}
|
}
|
||||||
isrc := strings.TrimSpace(t.ISRC)
|
isrc := strings.TrimSpace(t.ISRC)
|
||||||
if isrc == "" && strings.Contains(filenameFormat, "{isrc}") && t.SpotifyID != "" {
|
shouldResolveISRC := existingFileCheckMode == "isrc" || strings.Contains(filenameFormat, "{isrc}")
|
||||||
|
if isrc == "" && shouldResolveISRC && t.SpotifyID != "" {
|
||||||
isrc = backend.ResolveTrackISRC(t.SpotifyID)
|
isrc = backend.ResolveTrackISRC(t.SpotifyID)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1806,8 +1939,11 @@ func (a *App) CheckFilesExistence(outputDir string, rootDir string, tracks []Che
|
|||||||
}
|
}
|
||||||
|
|
||||||
fileExt := ".flac"
|
fileExt := ".flac"
|
||||||
if t.AudioFormat == "mp3" {
|
switch strings.ToLower(strings.TrimSpace(t.AudioFormat)) {
|
||||||
|
case "mp3":
|
||||||
fileExt = ".mp3"
|
fileExt = ".mp3"
|
||||||
|
case "m4a", "m4a-aac", "m4a-alac", "alac", "atmos", "apple":
|
||||||
|
fileExt = ".m4a"
|
||||||
}
|
}
|
||||||
|
|
||||||
expectedFilenameBase := backend.BuildExpectedFilename(
|
expectedFilenameBase := backend.BuildExpectedFilename(
|
||||||
@@ -1836,14 +1972,29 @@ 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)
|
||||||
res.FilePath = filepath.Base(expectedPath)
|
resultsChan <- result{index: idx, result: res}
|
||||||
} else {
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizedISRC := normalizeExistingFileIdentifier(isrc)
|
||||||
|
effectiveMode := existingFileCheckMode
|
||||||
|
if effectiveMode == "isrc" && normalizedISRC == "" {
|
||||||
|
effectiveMode = "filename"
|
||||||
|
}
|
||||||
|
|
||||||
|
switch effectiveMode {
|
||||||
|
case "isrc":
|
||||||
|
if path, ok := getLookupIndex().byISRC[normalizedISRC]; ok {
|
||||||
|
res.Exists = true
|
||||||
|
res.FilePath = path
|
||||||
|
}
|
||||||
|
default:
|
||||||
if fileInfo, err := os.Stat(expectedPath); err == nil && fileInfo.Size() > 100*1024 {
|
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 {
|
} else if path, ok := getLookupIndex().byFilename[filepath.Base(expectedPath)]; ok {
|
||||||
|
res.Exists = true
|
||||||
res.FilePath = expectedFilename
|
res.FilePath = path
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1852,39 +2003,10 @@ 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
|
||||||
@@ -1910,6 +2032,14 @@ 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 {
|
||||||
@@ -1931,6 +2061,27 @@ 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 {
|
||||||
@@ -1954,6 +2105,32 @@ 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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package backend
|
package backend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/sha256"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -10,6 +13,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -23,6 +27,76 @@ 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{
|
||||||
@@ -62,6 +136,12 @@ 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 {
|
||||||
|
|||||||
@@ -60,6 +60,40 @@ 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 {
|
||||||
|
|||||||
+189
-52
@@ -19,6 +19,11 @@ 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 == "" {
|
||||||
@@ -83,6 +88,50 @@ 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{
|
||||||
@@ -114,83 +163,163 @@ func resolveSystemExecutable(executableName string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetFFmpegPath() (string, error) {
|
func runExecutableVersionCheck(path string) error {
|
||||||
ffmpegDir, err := GetFFmpegDir()
|
cmd := exec.Command(path, "-version")
|
||||||
if err != nil {
|
setHideWindow(cmd)
|
||||||
return "", err
|
return cmd.Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeMacOSQuarantineAttribute(path string) error {
|
||||||
|
cmd := exec.Command("xattr", "-d", "com.apple.quarantine", path)
|
||||||
|
setHideWindow(cmd)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
trimmedOutput := strings.TrimSpace(string(output))
|
||||||
|
lowerOutput := strings.ToLower(trimmedOutput)
|
||||||
|
if strings.Contains(lowerOutput, "no such xattr") || strings.Contains(lowerOutput, "attribute not found") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if trimmedOutput != "" {
|
||||||
|
return fmt.Errorf("%w: %s", err, trimmedOutput)
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepareExecutableForUse(path string) error {
|
||||||
|
cleanedPath := filepath.Clean(strings.TrimSpace(path))
|
||||||
|
if cleanedPath == "" {
|
||||||
|
return fmt.Errorf("empty path")
|
||||||
|
}
|
||||||
|
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Chmod(cleanedPath, 0755); err != nil {
|
||||||
|
return fmt.Errorf("failed to mark executable: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if runtime.GOOS == "darwin" {
|
||||||
|
if err := removeMacOSQuarantineAttribute(cleanedPath); err != nil {
|
||||||
|
fmt.Printf("[FFmpeg] Warning: failed to remove macOS quarantine from %s: %v\n", cleanedPath, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveExecutablePath(executableName string) (string, string, error) {
|
||||||
|
ffmpegDir, err := GetFFmpegDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
localPath := filepath.Join(ffmpegDir, executableName)
|
||||||
|
nextDir := filepath.Join(filepath.Dir(ffmpegDir), ".spotiflac-next")
|
||||||
|
nextPath := filepath.Join(nextDir, executableName)
|
||||||
|
localExists := false
|
||||||
|
candidates := make([]executableCandidate, 0, 3)
|
||||||
|
seen := make(map[string]struct{}, 3)
|
||||||
|
|
||||||
|
if systemPath := resolveSystemExecutable(executableName); systemPath != "" {
|
||||||
|
candidates = appendExecutableCandidate(candidates, seen, systemPath, "system")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(localPath); err == nil {
|
||||||
|
localExists = true
|
||||||
|
candidates = appendExecutableCandidate(candidates, seen, localPath, "local")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !localExists {
|
||||||
|
if _, err := os.Stat(nextPath); err == nil {
|
||||||
|
if copyErr := copyExecutable(nextPath, localPath); copyErr == nil {
|
||||||
|
fmt.Printf("[FFmpeg] Copied %s from SpotiFLAC-Next folder\n", executableName)
|
||||||
|
candidates = appendExecutableCandidate(candidates, seen, localPath, "migrated")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastErr error
|
||||||
|
for _, candidate := range candidates {
|
||||||
|
if candidate.source != "system" {
|
||||||
|
if err := prepareExecutableForUse(candidate.path); err != nil {
|
||||||
|
lastErr = err
|
||||||
|
fmt.Printf("[FFmpeg] Skipping %s %s: %v\n", candidate.source, candidate.path, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ValidateExecutable(candidate.path); err != nil {
|
||||||
|
lastErr = err
|
||||||
|
fmt.Printf("[FFmpeg] Skipping %s %s: %v\n", candidate.source, candidate.path, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := runExecutableVersionCheck(candidate.path); err != nil {
|
||||||
|
lastErr = err
|
||||||
|
fmt.Printf("[FFmpeg] Skipping %s %s: %v\n", candidate.source, candidate.path, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidate.path, localPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(candidates) > 0 {
|
||||||
|
if lastErr != nil {
|
||||||
|
return "", localPath, fmt.Errorf("no working %s executable found: %w", executableName, lastErr)
|
||||||
|
}
|
||||||
|
return "", localPath, fmt.Errorf("no working %s executable found", executableName)
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", localPath, fmt.Errorf("%s not found in app directory or system path", executableName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetFFmpegPath() (string, error) {
|
||||||
ffmpegName := "ffmpeg"
|
ffmpegName := "ffmpeg"
|
||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
ffmpegName = "ffmpeg.exe"
|
ffmpegName = "ffmpeg.exe"
|
||||||
}
|
}
|
||||||
|
|
||||||
if path := resolveSystemExecutable(ffmpegName); path != "" {
|
path, localPath, err := resolveExecutablePath(ffmpegName)
|
||||||
return path, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
localPath := filepath.Join(ffmpegDir, ffmpegName)
|
|
||||||
if _, err := os.Stat(localPath); err == nil {
|
|
||||||
return localPath, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return localPath, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetFFprobePath() (string, error) {
|
|
||||||
ffmpegDir, err := GetFFmpegDir()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if localPath != "" {
|
||||||
|
return localPath, err
|
||||||
|
}
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetFFprobePath() (string, error) {
|
||||||
ffprobeName := "ffprobe"
|
ffprobeName := "ffprobe"
|
||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
ffprobeName = "ffprobe.exe"
|
ffprobeName = "ffprobe.exe"
|
||||||
}
|
}
|
||||||
|
|
||||||
if path := resolveSystemExecutable(ffprobeName); path != "" {
|
path, localPath, err := resolveExecutablePath(ffprobeName)
|
||||||
return path, nil
|
if err != nil {
|
||||||
|
if localPath != "" {
|
||||||
|
return localPath, err
|
||||||
|
}
|
||||||
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
localPath := filepath.Join(ffmpegDir, ffprobeName)
|
return path, nil
|
||||||
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) {
|
||||||
ffprobePath, err := GetFFprobePath()
|
_, 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) {
|
||||||
ffmpegPath, err := GetFFmpegPath()
|
if _, err := GetFFmpegPath(); err != nil {
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -507,6 +636,10 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -584,6 +717,10 @@ 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,17 +1,21 @@
|
|||||||
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
|
||||||
}
|
}
|
||||||
|
|||||||
+214
-10
@@ -1,6 +1,10 @@
|
|||||||
package backend
|
package backend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/sha256"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -10,6 +14,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -68,6 +73,57 @@ 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{
|
||||||
@@ -77,6 +133,57 @@ func NewQobuzDownloader() *QobuzDownloader {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func previewQobuzResponseBody(body []byte, maxLen int) string {
|
||||||
|
preview := strings.TrimSpace(string(body))
|
||||||
|
if len(preview) > maxLen {
|
||||||
|
return preview[:maxLen] + "..."
|
||||||
|
}
|
||||||
|
return preview
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildQobuzOpenTrackURL(trackID int64) string {
|
||||||
|
return fmt.Sprintf("https://open.qobuz.com/track/%d", trackID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getQobuzMusicDLDebugKey() (string, error) {
|
||||||
|
qobuzMusicDLDebugKeyOnce.Do(func() {
|
||||||
|
hasher := sha256.New()
|
||||||
|
for _, part := range qobuzMusicDLDebugKeySeedParts {
|
||||||
|
hasher.Write(part)
|
||||||
|
}
|
||||||
|
|
||||||
|
block, err := aes.NewCipher(hasher.Sum(nil))
|
||||||
|
if err != nil {
|
||||||
|
qobuzMusicDLDebugKeyErr = err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
qobuzMusicDLDebugKeyErr = err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed := make([]byte, 0, len(qobuzMusicDLDebugKeyCiphertext)+len(qobuzMusicDLDebugKeyTag))
|
||||||
|
sealed = append(sealed, qobuzMusicDLDebugKeyCiphertext...)
|
||||||
|
sealed = append(sealed, qobuzMusicDLDebugKeyTag...)
|
||||||
|
|
||||||
|
plaintext, err := gcm.Open(nil, qobuzMusicDLDebugKeyNonce, sealed, qobuzMusicDLDebugKeyAAD)
|
||||||
|
if err != nil {
|
||||||
|
qobuzMusicDLDebugKeyErr = err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
qobuzMusicDLDebugKey = string(plaintext)
|
||||||
|
})
|
||||||
|
|
||||||
|
if qobuzMusicDLDebugKeyErr != nil {
|
||||||
|
return "", qobuzMusicDLDebugKeyErr
|
||||||
|
}
|
||||||
|
|
||||||
|
return qobuzMusicDLDebugKey, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (q *QobuzDownloader) searchByISRC(isrc string) (*QobuzTrack, error) {
|
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_")
|
||||||
@@ -139,9 +246,6 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,6 +292,81 @@ 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" {
|
||||||
@@ -196,8 +375,6 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string, allowFal
|
|||||||
|
|
||||||
fmt.Printf("Getting download URL for track ID: %d with requested quality: %s\n", trackID, qualityCode)
|
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
|
||||||
@@ -205,21 +382,48 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string, allowFal
|
|||||||
Func func() (string, error)
|
Func func() (string, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
var providers []Provider
|
providerMap := make(map[string]Provider)
|
||||||
|
providerIDs := []string{GetQobuzMusicDLDownloadAPIURL()}
|
||||||
|
|
||||||
for _, api := range standardAPIs {
|
providerMap[GetQobuzMusicDLDownloadAPIURL()] = Provider{
|
||||||
|
Name: "MusicDL",
|
||||||
|
API: GetQobuzMusicDLDownloadAPIURL(),
|
||||||
|
Func: func() (string, error) {
|
||||||
|
return q.DownloadFromMusicDL(trackID, qual)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, api := range GetQobuzStreamAPIBaseURLs() {
|
||||||
currentAPI := api
|
currentAPI := api
|
||||||
providers = append(providers, Provider{
|
providerIDs = append(providerIDs, currentAPI)
|
||||||
|
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 _, p := range providers {
|
for _, providerID := range orderedProviderIDs {
|
||||||
|
p, ok := providerMap[providerID]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
fmt.Printf("Trying Provider: %s (Quality: %s)...\n", p.Name, qual)
|
fmt.Printf("Trying Provider: %s (Quality: %s)...\n", p.Name, qual)
|
||||||
|
|
||||||
url, err := p.Func()
|
url, err := p.Func()
|
||||||
|
|||||||
+189
-10
@@ -9,6 +9,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -47,10 +48,172 @@ 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 := GetRotatedTidalAPIList()
|
apis, err := getConfiguredTidalAPIAttemptList()
|
||||||
if err == nil && len(apis) > 0 {
|
if err == nil && len(apis) > 0 {
|
||||||
apiURL = apis[0]
|
apiURL = apis[0]
|
||||||
}
|
}
|
||||||
@@ -67,7 +230,7 @@ func NewTidalDownloader(apiURL string) *TidalDownloader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (t *TidalDownloader) GetAvailableAPIs() ([]string, error) {
|
func (t *TidalDownloader) GetAvailableAPIs() ([]string, error) {
|
||||||
apis, err := GetRotatedTidalAPIList()
|
apis, err := getConfiguredTidalAPIAttemptList()
|
||||||
if err == nil && len(apis) > 0 {
|
if err == nil && len(apis) > 0 {
|
||||||
return apis, nil
|
return apis, nil
|
||||||
}
|
}
|
||||||
@@ -173,10 +336,10 @@ func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (string,
|
|||||||
return "", fmt.Errorf("download URL not found in response")
|
return "", fmt.Errorf("download URL not found in response")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *TidalDownloader) DownloadFile(url, filepath string) error {
|
func (t *TidalDownloader) DownloadFile(url, filepath string, quality string) error {
|
||||||
|
|
||||||
if strings.HasPrefix(url, "MANIFEST:") {
|
if strings.HasPrefix(url, "MANIFEST:") {
|
||||||
return t.DownloadFromManifest(strings.TrimPrefix(url, "MANIFEST:"), filepath)
|
return t.DownloadFromManifest(strings.TrimPrefix(url, "MANIFEST:"), filepath, quality)
|
||||||
}
|
}
|
||||||
|
|
||||||
req, err := NewRequestWithDefaultHeaders(http.MethodGet, url, nil)
|
req, err := NewRequestWithDefaultHeaders(http.MethodGet, url, nil)
|
||||||
@@ -213,12 +376,18 @@ func (t *TidalDownloader) DownloadFile(url, filepath string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *TidalDownloader) DownloadFromManifest(manifestB64, outputPath string) error {
|
func (t *TidalDownloader) DownloadFromManifest(manifestB64, outputPath string, quality string) error {
|
||||||
directURL, initURL, mediaURLs, mimeType, err := parseManifest(manifestB64)
|
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,
|
||||||
}
|
}
|
||||||
@@ -433,7 +602,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); err != nil {
|
if err := t.DownloadFile(downloadURL, outputFilename, quality); err != nil {
|
||||||
cleanupTidalDownloadArtifacts(outputFilename)
|
cleanupTidalDownloadArtifacts(outputFilename)
|
||||||
return outputFilename, err
|
return outputFilename, err
|
||||||
}
|
}
|
||||||
@@ -493,6 +662,10 @@ func (t *TidalDownloader) Download(spotifyTrackID, outputDir, quality, filenameF
|
|||||||
return "", fmt.Errorf("songlink/songstats couldn't find Tidal URL: %w", err)
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -550,10 +723,12 @@ 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 {
|
||||||
|
|
||||||
@@ -562,6 +737,7 @@ 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -576,6 +752,8 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
|
|||||||
} else {
|
} else {
|
||||||
selectedCodecs = as.Codecs
|
selectedCodecs = as.Codecs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
selectedMimeType = as.MimeType
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -583,6 +761,7 @@ 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -608,7 +787,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, "", nil
|
return "", initURL, mediaURLs, dashMimeType, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("Using regex fallback for DASH manifest...")
|
fmt.Println("Using regex fallback for DASH manifest...")
|
||||||
@@ -655,7 +834,7 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
|
|||||||
mediaURLs = append(mediaURLs, mediaURL)
|
mediaURLs = append(mediaURLs, mediaURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
return "", initURL, mediaURLs, "", nil
|
return "", initURL, mediaURLs, dashMimeType, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *TidalDownloader) downloadWithRotatingAPIs(trackID int64, outputFilename string, quality string, allowFallback bool) (string, error) {
|
func (t *TidalDownloader) downloadWithRotatingAPIs(trackID int64, outputFilename string, quality string, allowFallback bool) (string, error) {
|
||||||
@@ -684,7 +863,7 @@ func (t *TidalDownloader) downloadWithRotatingAPIs(trackID int64, outputFilename
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (t *TidalDownloader) tryDownloadAcrossTidalAPIs(trackID int64, outputFilename string, quality string, refreshed bool) (string, error) {
|
func (t *TidalDownloader) tryDownloadAcrossTidalAPIs(trackID int64, outputFilename string, quality string, refreshed bool) (string, error) {
|
||||||
apis, err := GetRotatedTidalAPIList()
|
apis, err := getConfiguredTidalAPIAttemptList()
|
||||||
if err != nil && len(apis) == 0 {
|
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)
|
||||||
}
|
}
|
||||||
@@ -706,7 +885,7 @@ func (t *TidalDownloader) tryDownloadAcrossTidalAPIs(trackID int64, outputFilena
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := downloader.DownloadFile(downloadURL, outputFilename); err != nil {
|
if err := downloader.DownloadFile(downloadURL, outputFilename, quality); 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))
|
||||||
|
|||||||
@@ -1,238 +0,0 @@
|
|||||||
package backend
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
const tidalAltDownloadAPIBaseURL = "https://tidal.spotbye.qzz.io/get"
|
|
||||||
|
|
||||||
type TidalAltAPIResponse struct {
|
|
||||||
Title string `json:"title"`
|
|
||||||
Link string `json:"link"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildTidalOutputPath(outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyTrackNumber, spotifyDiscNumber int, isrcOverride string, useFirstArtistOnly bool) (string, bool, error) {
|
|
||||||
if outputDir != "." {
|
|
||||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
|
||||||
return "", false, fmt.Errorf("directory error: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
artistNameForFile := sanitizeFilename(spotifyArtistName)
|
|
||||||
albumArtistForFile := sanitizeFilename(spotifyAlbumArtist)
|
|
||||||
if useFirstArtistOnly {
|
|
||||||
artistNameForFile = sanitizeFilename(GetFirstArtist(spotifyArtistName))
|
|
||||||
albumArtistForFile = sanitizeFilename(GetFirstArtist(spotifyAlbumArtist))
|
|
||||||
}
|
|
||||||
|
|
||||||
trackTitleForFile := sanitizeFilename(spotifyTrackName)
|
|
||||||
albumTitleForFile := sanitizeFilename(spotifyAlbumName)
|
|
||||||
|
|
||||||
filename := buildTidalFilename(trackTitleForFile, artistNameForFile, albumTitleForFile, albumArtistForFile, spotifyReleaseDate, spotifyTrackNumber, spotifyDiscNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber, isrcOverride)
|
|
||||||
outputFilename := filepath.Join(outputDir, filename)
|
|
||||||
|
|
||||||
outputFilename, alreadyExists := ResolveOutputPathForDownload(outputFilename, GetRedownloadWithSuffixSetting())
|
|
||||||
return outputFilename, alreadyExists, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func finalizeTidalDownload(outputFilename, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL string, useSingleGenre bool, embedGenre bool) {
|
|
||||||
trackTitle := spotifyTrackName
|
|
||||||
artistName := spotifyArtistName
|
|
||||||
albumTitle := spotifyAlbumName
|
|
||||||
|
|
||||||
type mbResult struct {
|
|
||||||
ISRC string
|
|
||||||
Metadata Metadata
|
|
||||||
}
|
|
||||||
|
|
||||||
metaChan := make(chan mbResult, 1)
|
|
||||||
if embedGenre && spotifyURL != "" {
|
|
||||||
go func() {
|
|
||||||
res := mbResult{}
|
|
||||||
var isrc string
|
|
||||||
parts := strings.Split(spotifyURL, "/")
|
|
||||||
if len(parts) > 0 {
|
|
||||||
sID := strings.Split(parts[len(parts)-1], "?")[0]
|
|
||||||
if sID != "" {
|
|
||||||
client := NewSongLinkClient()
|
|
||||||
if val, err := client.GetISRC(sID); err == nil {
|
|
||||||
isrc = val
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
res.ISRC = isrc
|
|
||||||
if isrc != "" {
|
|
||||||
if ShouldSkipMusicBrainzMetadataFetch() {
|
|
||||||
fmt.Println("Skipping MusicBrainz metadata fetch because status check is offline.")
|
|
||||||
} else {
|
|
||||||
fmt.Println("Fetching MusicBrainz metadata...")
|
|
||||||
if fetchedMeta, err := FetchMusicBrainzMetadata(isrc, trackTitle, artistName, albumTitle, useSingleGenre, embedGenre); err == nil {
|
|
||||||
res.Metadata = fetchedMeta
|
|
||||||
fmt.Println("✓ MusicBrainz metadata fetched")
|
|
||||||
} else {
|
|
||||||
fmt.Printf("Warning: Failed to fetch MusicBrainz metadata: %v\n", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
metaChan <- res
|
|
||||||
}()
|
|
||||||
} else {
|
|
||||||
close(metaChan)
|
|
||||||
}
|
|
||||||
|
|
||||||
isrc := strings.TrimSpace(isrcOverride)
|
|
||||||
var mbMeta Metadata
|
|
||||||
if spotifyURL != "" {
|
|
||||||
result := <-metaChan
|
|
||||||
if isrc == "" {
|
|
||||||
isrc = result.ISRC
|
|
||||||
}
|
|
||||||
mbMeta = result.Metadata
|
|
||||||
}
|
|
||||||
|
|
||||||
upc := ""
|
|
||||||
if spotifyURL != "" {
|
|
||||||
if identifiers, err := GetSpotifyTrackIdentifiersDirect(spotifyURL); err == nil || identifiers.ISRC != "" || identifiers.UPC != "" {
|
|
||||||
if strings.TrimSpace(isrc) == "" && strings.TrimSpace(identifiers.ISRC) != "" {
|
|
||||||
isrc = strings.TrimSpace(identifiers.ISRC)
|
|
||||||
}
|
|
||||||
upc = strings.TrimSpace(identifiers.UPC)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println("Adding metadata...")
|
|
||||||
|
|
||||||
coverPath := ""
|
|
||||||
if spotifyCoverURL != "" {
|
|
||||||
coverPath = outputFilename + ".cover.jpg"
|
|
||||||
coverClient := NewCoverClient()
|
|
||||||
if err := coverClient.DownloadCoverToPath(spotifyCoverURL, coverPath, embedMaxQualityCover); err != nil {
|
|
||||||
fmt.Printf("Warning: Failed to download Spotify cover: %v\n", err)
|
|
||||||
coverPath = ""
|
|
||||||
} else {
|
|
||||||
defer os.Remove(coverPath)
|
|
||||||
fmt.Println("Spotify cover downloaded")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
trackNumberToEmbed := spotifyTrackNumber
|
|
||||||
if trackNumberToEmbed == 0 {
|
|
||||||
trackNumberToEmbed = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
metadata := Metadata{
|
|
||||||
Title: trackTitle,
|
|
||||||
Artist: artistName,
|
|
||||||
Album: albumTitle,
|
|
||||||
AlbumArtist: spotifyAlbumArtist,
|
|
||||||
Date: spotifyReleaseDate,
|
|
||||||
TrackNumber: trackNumberToEmbed,
|
|
||||||
TotalTracks: spotifyTotalTracks,
|
|
||||||
DiscNumber: spotifyDiscNumber,
|
|
||||||
TotalDiscs: spotifyTotalDiscs,
|
|
||||||
URL: spotifyURL,
|
|
||||||
Comment: spotifyURL,
|
|
||||||
Copyright: spotifyCopyright,
|
|
||||||
Publisher: spotifyPublisher,
|
|
||||||
Composer: spotifyComposer,
|
|
||||||
Separator: metadataSeparator,
|
|
||||||
Description: "https://github.com/spotbye/SpotiFLAC",
|
|
||||||
ISRC: isrc,
|
|
||||||
UPC: upc,
|
|
||||||
Genre: mbMeta.Genre,
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := EmbedMetadata(outputFilename, metadata, coverPath); err != nil {
|
|
||||||
fmt.Printf("Tagging failed: %v\n", err)
|
|
||||||
} else {
|
|
||||||
fmt.Println("Metadata saved")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *TidalDownloader) GetAltDownloadURLFromSpotify(spotifyTrackID string) (string, error) {
|
|
||||||
spotifyTrackID = strings.TrimSpace(spotifyTrackID)
|
|
||||||
if spotifyTrackID == "" {
|
|
||||||
return "", fmt.Errorf("spotify track ID is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
apiURL := fmt.Sprintf("%s/%s", tidalAltDownloadAPIBaseURL, spotifyTrackID)
|
|
||||||
fmt.Printf("Tidal Alt. API URL: %s\n", apiURL)
|
|
||||||
|
|
||||||
req, err := NewRequestWithDefaultHeaders(http.MethodGet, apiURL, nil)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to create Tidal Alt. request: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := t.client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to get Tidal Alt. download URL: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to read Tidal Alt. response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
preview := strings.TrimSpace(string(body))
|
|
||||||
if len(preview) > 200 {
|
|
||||||
preview = preview[:200] + "..."
|
|
||||||
}
|
|
||||||
return "", fmt.Errorf("Tidal Alt. returned status %d: %s", resp.StatusCode, preview)
|
|
||||||
}
|
|
||||||
|
|
||||||
var payload TidalAltAPIResponse
|
|
||||||
if err := json.Unmarshal(body, &payload); err != nil {
|
|
||||||
return "", fmt.Errorf("failed to decode Tidal Alt. response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
downloadURL := strings.TrimSpace(payload.Link)
|
|
||||||
if downloadURL == "" {
|
|
||||||
return "", fmt.Errorf("Tidal Alt. response did not include a download link")
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println("✓ Tidal Alt. download URL found")
|
|
||||||
return downloadURL, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *TidalDownloader) DownloadAlt(spotifyTrackID, outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL string, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) {
|
|
||||||
spotifyTrackID = strings.TrimSpace(spotifyTrackID)
|
|
||||||
if spotifyTrackID == "" {
|
|
||||||
return "", fmt.Errorf("spotify track ID is required for Tidal Alt.")
|
|
||||||
}
|
|
||||||
|
|
||||||
outputFilename, alreadyExists, err := buildTidalOutputPath(outputDir, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyTrackNumber, spotifyDiscNumber, isrcOverride, useFirstArtistOnly)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if alreadyExists {
|
|
||||||
fmt.Printf("File already exists: %s (%.2f MB)\n", outputFilename, float64(mustFileSize(outputFilename))/(1024*1024))
|
|
||||||
return "EXISTS:" + outputFilename, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("Using Tidal Alt. for Spotify track: %s\n", spotifyTrackID)
|
|
||||||
|
|
||||||
downloadURL, err := t.GetAltDownloadURLFromSpotify(spotifyTrackID)
|
|
||||||
if err != nil {
|
|
||||||
return outputFilename, err
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("Downloading to: %s\n", outputFilename)
|
|
||||||
if err := t.DownloadFile(downloadURL, outputFilename); err != nil {
|
|
||||||
cleanupTidalDownloadArtifacts(outputFilename)
|
|
||||||
return outputFilename, err
|
|
||||||
}
|
|
||||||
|
|
||||||
finalizeTidalDownload(outputFilename, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL, useSingleGenre, embedGenre)
|
|
||||||
|
|
||||||
fmt.Println("Done")
|
|
||||||
fmt.Println("✓ Downloaded successfully from Tidal Alt.")
|
|
||||||
return outputFilename, nil
|
|
||||||
}
|
|
||||||
@@ -20,6 +20,7 @@
|
|||||||
"@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",
|
||||||
@@ -55,4 +56,4 @@
|
|||||||
"typescript-eslint": "^8.56.1",
|
"typescript-eslint": "^8.56.1",
|
||||||
"vite": "^7.3.1"
|
"vite": "^7.3.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
867c45db7982e126a7249d80210f23be
|
8864b4f7b7971b624d1ba25030f2db4e
|
||||||
Generated
+3
@@ -32,6 +32,9 @@ 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)
|
||||||
|
|||||||
@@ -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);
|
applyFont(savedSettings.fontFamily, savedSettings.customFonts);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
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);
|
applyFont(settings.fontFamily, settings.customFonts);
|
||||||
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} currentDownloadInfo={download.currentDownloadInfo} currentPage={currentListPage} itemsPerPage={ITEMS_PER_PAGE} downloadedLyrics={lyrics.downloadedLyrics} failedLyrics={lyrics.failedLyrics} skippedLyrics={lyrics.skippedLyrics} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} checkingAvailabilityTrack={availability.checkingTrackId} availabilityMap={availability.availabilityMap} downloadedCovers={cover.downloadedCovers} failedCovers={cover.failedCovers} skippedCovers={cover.skippedCovers} downloadingCoverTrack={cover.downloadingCoverTrack} isBulkDownloadingCovers={cover.isBulkDownloadingCovers} isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics} isMetadataLoading={metadata.loading} onSearchChange={handleSearchChange} onSortChange={setSortBy} onToggleTrack={toggleTrackSelection} onToggleSelectAll={toggleSelectAll} onDownloadTrack={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, album_info.name, position, albumArtist, releaseDate, discNumber, true)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, album_info.name, position, trackId, albumArtist, releaseDate, discNumber, true)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, album_info.name, undefined, true)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, album_info.name, true)} onDownloadAll={() => download.handleDownloadAll(track_list, album_info.name, true)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, album_info.name, true)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onPageChange={setCurrentListPage} onBack={metadata.resetMetadata} onArtistClick={async (artist) => {
|
return (<AlbumInfo albumInfo={album_info} trackList={track_list} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={download.downloadedTracks} failedTracks={download.failedTracks} skippedTracks={download.skippedTracks} downloadingTrack={download.downloadingTrack} isDownloading={download.isDownloading} bulkDownloadType={download.bulkDownloadType} downloadProgress={download.downloadProgress} downloadRemainingCount={download.downloadRemainingCount} currentDownloadInfo={download.currentDownloadInfo} currentPage={currentListPage} itemsPerPage={ITEMS_PER_PAGE} downloadedLyrics={lyrics.downloadedLyrics} failedLyrics={lyrics.failedLyrics} skippedLyrics={lyrics.skippedLyrics} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} checkingAvailabilityTrack={availability.checkingTrackId} availabilityMap={availability.availabilityMap} downloadedCovers={cover.downloadedCovers} failedCovers={cover.failedCovers} skippedCovers={cover.skippedCovers} downloadingCoverTrack={cover.downloadingCoverTrack} isBulkDownloadingCovers={cover.isBulkDownloadingCovers} isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics} isMetadataLoading={metadata.loading} onSearchChange={handleSearchChange} onSortChange={setSortBy} onToggleTrack={toggleTrackSelection} onToggleSelectAll={toggleSelectAll} onDownloadTrack={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, album_info.name, position, albumArtist, releaseDate, discNumber, true)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, album_info.name, position, trackId, albumArtist, releaseDate, discNumber, true)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, album_info.name, undefined, true)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, album_info.name, true)} onDownloadAll={() => download.handleDownloadAll(track_list, album_info.name, true)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, album_info.name, true)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onPageChange={setCurrentListPage} onBack={metadata.resetMetadata} onArtistClick={async (artist) => {
|
||||||
const pendingArtistUrl = artist.external_urls.replace(/\/$/, "") + "/discography/all";
|
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} currentDownloadInfo={download.currentDownloadInfo} currentPage={currentListPage} itemsPerPage={ITEMS_PER_PAGE} downloadedLyrics={lyrics.downloadedLyrics} failedLyrics={lyrics.failedLyrics} skippedLyrics={lyrics.skippedLyrics} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} checkingAvailabilityTrack={availability.checkingTrackId} availabilityMap={availability.availabilityMap} downloadedCovers={cover.downloadedCovers} failedCovers={cover.failedCovers} skippedCovers={cover.skippedCovers} downloadingCoverTrack={cover.downloadingCoverTrack} isBulkDownloadingCovers={cover.isBulkDownloadingCovers} isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics} isMetadataLoading={metadata.loading} onSearchChange={handleSearchChange} onSortChange={setSortBy} onToggleTrack={toggleTrackSelection} onToggleSelectAll={toggleSelectAll} onDownloadTrack={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, playlistFolderName, position, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, playlistFolderName, position, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, playlistFolderName)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, playlistFolderName)} onDownloadAll={() => download.handleDownloadAll(track_list, playlistFolderName)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, playlistFolderName)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onPageChange={setCurrentListPage} onBack={metadata.resetMetadata} onAlbumClick={metadata.handleAlbumClick} onArtistClick={async (artist) => {
|
return (<PlaylistInfo playlistInfo={playlist_info} trackList={track_list} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={download.downloadedTracks} failedTracks={download.failedTracks} skippedTracks={download.skippedTracks} downloadingTrack={download.downloadingTrack} isDownloading={download.isDownloading} bulkDownloadType={download.bulkDownloadType} downloadProgress={download.downloadProgress} downloadRemainingCount={download.downloadRemainingCount} currentDownloadInfo={download.currentDownloadInfo} currentPage={currentListPage} itemsPerPage={ITEMS_PER_PAGE} downloadedLyrics={lyrics.downloadedLyrics} failedLyrics={lyrics.failedLyrics} skippedLyrics={lyrics.skippedLyrics} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} checkingAvailabilityTrack={availability.checkingTrackId} availabilityMap={availability.availabilityMap} downloadedCovers={cover.downloadedCovers} failedCovers={cover.failedCovers} skippedCovers={cover.skippedCovers} downloadingCoverTrack={cover.downloadingCoverTrack} isBulkDownloadingCovers={cover.isBulkDownloadingCovers} isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics} isMetadataLoading={metadata.loading} onSearchChange={handleSearchChange} onSortChange={setSortBy} onToggleTrack={toggleTrackSelection} onToggleSelectAll={toggleSelectAll} onDownloadTrack={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, playlistFolderName, position, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, playlistFolderName, position, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, playlistFolderName)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, playlistFolderName)} onDownloadAll={() => download.handleDownloadAll(track_list, playlistFolderName)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, playlistFolderName)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onPageChange={setCurrentListPage} onBack={metadata.resetMetadata} onAlbumClick={metadata.handleAlbumClick} onArtistClick={async (artist) => {
|
||||||
const pendingArtistUrl = artist.external_urls.replace(/\/$/, "") + "/discography/all";
|
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} currentDownloadInfo={download.currentDownloadInfo} currentPage={currentListPage} itemsPerPage={ITEMS_PER_PAGE} downloadedLyrics={lyrics.downloadedLyrics} failedLyrics={lyrics.failedLyrics} skippedLyrics={lyrics.skippedLyrics} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} checkingAvailabilityTrack={availability.checkingTrackId} availabilityMap={availability.availabilityMap} downloadedCovers={cover.downloadedCovers} failedCovers={cover.failedCovers} skippedCovers={cover.skippedCovers} downloadingCoverTrack={cover.downloadingCoverTrack} isBulkDownloadingCovers={cover.isBulkDownloadingCovers} isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics} isMetadataLoading={metadata.loading} onSearchChange={handleSearchChange} onSortChange={setSortBy} onToggleTrack={toggleTrackSelection} onToggleSelectAll={toggleSelectAll} onDownloadTrack={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, artist_info.name, position, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, artist_info.name, position, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, artist_info.name)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, artist_info.name)} onDownloadAll={() => download.handleDownloadAll(track_list, artist_info.name)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, artist_info.name)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onAlbumClick={metadata.handleAlbumClick} onBack={metadata.resetMetadata} onArtistClick={async (artist) => {
|
return (<ArtistInfo artistInfo={artist_info} albumList={album_list} trackList={track_list} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={download.downloadedTracks} failedTracks={download.failedTracks} skippedTracks={download.skippedTracks} downloadingTrack={download.downloadingTrack} isDownloading={download.isDownloading} bulkDownloadType={download.bulkDownloadType} downloadProgress={download.downloadProgress} downloadRemainingCount={download.downloadRemainingCount} currentDownloadInfo={download.currentDownloadInfo} currentPage={currentListPage} itemsPerPage={ITEMS_PER_PAGE} downloadedLyrics={lyrics.downloadedLyrics} failedLyrics={lyrics.failedLyrics} skippedLyrics={lyrics.skippedLyrics} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} checkingAvailabilityTrack={availability.checkingTrackId} availabilityMap={availability.availabilityMap} downloadedCovers={cover.downloadedCovers} failedCovers={cover.failedCovers} skippedCovers={cover.skippedCovers} downloadingCoverTrack={cover.downloadingCoverTrack} isBulkDownloadingCovers={cover.isBulkDownloadingCovers} isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics} isMetadataLoading={metadata.loading} onSearchChange={handleSearchChange} onSortChange={setSortBy} onToggleTrack={toggleTrackSelection} onToggleSelectAll={toggleSelectAll} onDownloadTrack={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, artist_info.name, position, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, artist_info.name, position, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, artist_info.name)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, artist_info.name)} onDownloadAll={() => download.handleDownloadAll(track_list, artist_info.name)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, artist_info.name)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onAlbumClick={metadata.handleAlbumClick} onBack={metadata.resetMetadata} onArtistClick={async (artist) => {
|
||||||
const pendingArtistUrl = artist.external_urls.replace(/\/$/, "") + "/discography/all";
|
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);
|
applyFont(savedSettings.fontFamily, savedSettings.customFonts);
|
||||||
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-[425px] p-6 [&>button]:hidden">
|
<DialogContent className="sm:max-w-106.25 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-[425px] [&>button]:hidden">
|
<DialogContent className="sm:max-w-106.25 [&>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-[450px] [&>button]:hidden p-6 gap-5">
|
<DialogContent className="max-w-112.5 [&>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
|
||||||
|
|||||||
@@ -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 was created as a thank-you to everyone who has supported SpotiFLAC on Ko-fi.
|
This project released as a token of appreciation for those who have supported SpotiFLAC on Ko-fi. It’s not a paid product, but it’s shared privately through a supporter-only post.
|
||||||
</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-[22px] w-[22px] rounded-sm shadow-sm" alt={item.alt}/>
|
<img src={item.icon} className="h-5.5 w-5.5 rounded-sm shadow-sm" alt={item.alt}/>
|
||||||
<span className={`${projectBodyClass} text-muted-foreground`}>
|
<span className={`${projectBodyClass} text-muted-foreground`}>
|
||||||
{item.label}
|
{item.label}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ 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;
|
||||||
@@ -77,7 +78,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, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, isMetadataLoading = false, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onPageChange, onArtistClick, onTrackClick, onBack, }: AlbumInfoProps) {
|
export function AlbumInfo({ albumInfo, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, downloadRemainingCount, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, isMetadataLoading = false, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onPageChange, onArtistClick, onTrackClick, onBack, }: AlbumInfoProps) {
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
const albumArtistNames = splitArtistNames(albumInfo.artists);
|
const albumArtistNames = splitArtistNames(albumInfo.artists);
|
||||||
const artistSeparator = albumInfo.artists.includes(";") ? "; " : ", ";
|
const artistSeparator = albumInfo.artists.includes(";") ? "; " : ", ";
|
||||||
@@ -270,7 +271,7 @@ export function AlbumInfo({ albumInfo, trackList, searchQuery, sortBy, selectedT
|
|||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>)}
|
</Tooltip>)}
|
||||||
</div>
|
</div>
|
||||||
{isDownloading && (<DownloadProgress progress={downloadProgress} currentTrack={currentDownloadInfo} onStop={onStopDownload}/>)}
|
{isDownloading && (<DownloadProgress progress={downloadProgress} remainingCount={downloadRemainingCount} currentTrack={currentDownloadInfo} onStop={onStopDownload}/>)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ 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;
|
||||||
@@ -95,7 +96,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, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, isMetadataLoading = false, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onAlbumClick, onArtistClick, onPageChange, onTrackClick, onBack, }: ArtistInfoProps) {
|
export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, downloadRemainingCount, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, isMetadataLoading = false, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onAlbumClick, onArtistClick, onPageChange, onTrackClick, onBack, }: ArtistInfoProps) {
|
||||||
const [downloadingHeader, setDownloadingHeader] = useState(false);
|
const [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);
|
||||||
@@ -325,7 +326,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-gradient-to-t from-black via-black/50 to-transparent"/>
|
<div className="absolute inset-0 bg-linear-to-t from-black via-black/50 to-transparent"/>
|
||||||
{onBack && (<div className="absolute top-4 right-4 z-10">
|
{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"/>
|
||||||
@@ -563,7 +564,7 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
|
|||||||
Filter Albums
|
Filter Albums
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="sm:max-w-[500px] h-[80vh] flex flex-col">
|
<DialogContent className="sm:max-w-125 h-[80vh] flex flex-col">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Select Albums</DialogTitle>
|
<DialogTitle>Select Albums</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
@@ -634,7 +635,7 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
|
|||||||
</Tooltip>)}
|
</Tooltip>)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{isDownloading && (<DownloadProgress progress={downloadProgress} currentTrack={currentDownloadInfo} onStop={onStopDownload}/>)}
|
{isDownloading && (<DownloadProgress progress={downloadProgress} remainingCount={downloadRemainingCount} currentTrack={currentDownloadInfo} onStop={onStopDownload}/>)}
|
||||||
<SearchAndSort searchQuery={searchQuery} sortBy={sortBy} onSearchChange={onSearchChange} onSortChange={onSortChange}/>
|
<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>)}
|
||||||
|
|||||||
@@ -3,14 +3,17 @@ 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, currentTrack, onStop }: DownloadProgressProps) {
|
export function DownloadProgress({ progress, remainingCount = 0, 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"/>
|
||||||
@@ -20,7 +23,7 @@ export function DownloadProgress({ progress, currentTrack, onStop }: DownloadPro
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{clampedProgress}% -{" "}
|
{clampedProgress}% • {remainingLabel} -{" "}
|
||||||
{currentTrack
|
{currentTrack
|
||||||
? `${currentTrack.name} - ${currentTrack.artists}`
|
? `${currentTrack.name} - ${currentTrack.artists}`
|
||||||
: "Preparing download..."}
|
: "Preparing download..."}
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, Pagi
|
|||||||
import { GetDownloadHistory, ClearDownloadHistory, GetPreviewURL, GetFetchHistory, DeleteDownloadHistoryItem, DeleteFetchHistoryItem, ClearFetchHistoryByType } from "../../wailsjs/go/main/App";
|
import { GetDownloadHistory, ClearDownloadHistory, GetPreviewURL, GetFetchHistory, DeleteDownloadHistoryItem, DeleteFetchHistoryItem, ClearFetchHistoryByType } from "../../wailsjs/go/main/App";
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { openExternal } from "@/lib/utils";
|
import { openExternal } from "@/lib/utils";
|
||||||
import { SPOTIFY_PREVIEW_VOLUME } from "@/lib/preview";
|
import { getPreviewVolume } 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);
|
||||||
@@ -21,6 +22,37 @@ 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;
|
||||||
@@ -57,7 +89,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 audioRef = useRef<HTMLAudioElement | null>(null);
|
const playbackRef = useRef<PreviewPlayback | 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");
|
||||||
@@ -122,9 +154,8 @@ export function HistoryPage({ onHistorySelect }: HistoryPageProps) {
|
|||||||
}, [activeTab]);
|
}, [activeTab]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (audioRef.current) {
|
playbackRef.current?.destroy();
|
||||||
audioRef.current.pause();
|
playbackRef.current = null;
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -180,20 +211,35 @@ 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) {
|
||||||
audioRef.current?.pause();
|
playbackRef.current?.destroy();
|
||||||
|
playbackRef.current = null;
|
||||||
setPlayingPreviewId(null);
|
setPlayingPreviewId(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (audioRef.current) {
|
if (playbackRef.current) {
|
||||||
audioRef.current.pause();
|
playbackRef.current.destroy();
|
||||||
|
playbackRef.current = null;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const url = await GetPreviewURL(spotifyId);
|
const url = await GetPreviewURL(spotifyId);
|
||||||
if (url) {
|
if (url) {
|
||||||
const audio = new Audio(url);
|
const playback = await createPreviewPlayback(url, getPreviewVolume());
|
||||||
audioRef.current = audio;
|
const audio = playback.audio;
|
||||||
audio.volume = SPOTIFY_PREVIEW_VOLUME;
|
playbackRef.current = playback;
|
||||||
audio.onended = () => setPlayingPreviewId(null);
|
audio.onended = () => {
|
||||||
|
setPlayingPreviewId(null);
|
||||||
|
if (playbackRef.current?.audio === audio) {
|
||||||
|
playbackRef.current.destroy();
|
||||||
|
playbackRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
audio.onerror = () => {
|
||||||
|
setPlayingPreviewId(null);
|
||||||
|
if (playbackRef.current?.audio === audio) {
|
||||||
|
playbackRef.current.destroy();
|
||||||
|
playbackRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
audio.play();
|
audio.play();
|
||||||
setPlayingPreviewId(id);
|
setPlayingPreviewId(id);
|
||||||
}
|
}
|
||||||
@@ -271,7 +317,7 @@ export function HistoryPage({ onHistorySelect }: HistoryPageProps) {
|
|||||||
<Input placeholder="Search downloads..." value={downloadSearchQuery} onChange={(e) => setDownloadSearchQuery(e.target.value)} className="pl-8 h-9"/>
|
<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-[180px] h-9">
|
<SelectTrigger className="w-45 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>
|
||||||
@@ -329,10 +375,10 @@ export function HistoryPage({ onHistorySelect }: HistoryPageProps) {
|
|||||||
<td className="p-3 align-middle text-sm text-muted-foreground hidden md:table-cell">
|
<td className="p-3 align-middle text-sm text-muted-foreground hidden md:table-cell">
|
||||||
<div className="truncate">{item.album}</div>
|
<div className="truncate">{item.album}</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3 align-middle text-left hidden lg:table-cell">
|
<td className="p-3 align-middle text-left hidden lg:table-cell">
|
||||||
<div className="flex flex-col items-start gap-1">
|
<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">
|
||||||
{['HI_RES_LOSSLESS', 'LOSSLESS', 'flac', '6', '7', '27'].includes(item.format?.toLowerCase() || '') ? 'FLAC' : item.format?.toUpperCase()}
|
{getHistoryFormatLabel(item)}
|
||||||
</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>
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ 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;
|
||||||
@@ -88,7 +89,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, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, isMetadataLoading = false, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onPageChange, onAlbumClick, onArtistClick, onTrackClick, onBack, }: PlaylistInfoProps) {
|
export function PlaylistInfo({ playlistInfo, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, downloadRemainingCount, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, isMetadataLoading = false, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onPageChange, onAlbumClick, onArtistClick, onTrackClick, onBack, }: PlaylistInfoProps) {
|
||||||
const settings = getSettings();
|
const 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);
|
||||||
@@ -235,7 +236,7 @@ export function PlaylistInfo({ playlistInfo, trackList, searchQuery, sortBy, sel
|
|||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>)}
|
</Tooltip>)}
|
||||||
</div>
|
</div>
|
||||||
{isDownloading && (<DownloadProgress progress={downloadProgress} currentTrack={currentDownloadInfo} onStop={onStopDownload}/>)}
|
{isDownloading && (<DownloadProgress progress={downloadProgress} remainingCount={downloadRemainingCount} currentTrack={currentDownloadInfo} onStop={onStopDownload}/>)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -1,28 +1,37 @@
|
|||||||
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 } from "lucide-react";
|
import { FolderOpen, Save, RotateCcw, Info, ArrowRight, MonitorCog, FolderCog, Router, FolderLock, Plus, Trash2, ExternalLink } from "lucide-react";
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog";
|
import { 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, FONT_OPTIONS, FOLDER_PRESETS, FILENAME_PRESETS, TEMPLATE_VARIABLES, type Settings as SettingsType, type FontFamily, type FolderPreset, type FilenamePreset, } from "@/lib/settings";
|
import { getSettings, getSettingsWithDefaults, saveSettings, resetToDefaultSettings, applyThemeMode, applyFont, getFontOptions, parseGoogleFontUrl, loadGoogleFontUrl, loadCustomFonts, saveCustomFonts, FOLDER_PRESETS, FILENAME_PRESETS, TEMPLATE_VARIABLES, type Settings as SettingsType, type FontFamily, type CustomFontFamily, type FolderPreset, type FilenamePreset, type ExistingFileCheckMode, } from "@/lib/settings";
|
||||||
import { themes, applyTheme } from "@/lib/themes";
|
import { themes, applyTheme } from "@/lib/themes";
|
||||||
import { SelectFolder, OpenConfigFolder } from "../../wailsjs/go/main/App";
|
import { SelectFolder, OpenConfigFolder, CheckCustomTidalAPI } from "../../wailsjs/go/main/App";
|
||||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
import { 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();
|
||||||
@@ -55,14 +64,20 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
applyThemeMode(tempSettings.themeMode);
|
applyThemeMode(tempSettings.themeMode);
|
||||||
applyTheme(tempSettings.theme);
|
applyTheme(tempSettings.theme);
|
||||||
applyFont(tempSettings.fontFamily);
|
applyFont(tempSettings.fontFamily, tempSettings.customFonts);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setIsDark(document.documentElement.classList.contains("dark"));
|
setIsDark(document.documentElement.classList.contains("dark"));
|
||||||
}, 0);
|
}, 0);
|
||||||
}, [tempSettings.themeMode, tempSettings.theme, tempSettings.fontFamily]);
|
}, [tempSettings.themeMode, tempSettings.theme, tempSettings.fontFamily, tempSettings.customFonts]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (showAddFontDialog && parsedAddFont) {
|
||||||
|
loadGoogleFontUrl(parsedAddFont.url, "spotiflac-add-font-preview");
|
||||||
|
}
|
||||||
|
}, [showAddFontDialog, parsedAddFont]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadDefaults = async () => {
|
const loadDefaults = async () => {
|
||||||
if (!savedSettings.downloadPath) {
|
const currentSettings = getSettings();
|
||||||
|
if (!currentSettings.downloadPath) {
|
||||||
const settingsWithDefaults = await getSettingsWithDefaults();
|
const settingsWithDefaults = await getSettingsWithDefaults();
|
||||||
setSavedSettings(settingsWithDefaults);
|
setSavedSettings(settingsWithDefaults);
|
||||||
setTempSettings(settingsWithDefaults);
|
setTempSettings(settingsWithDefaults);
|
||||||
@@ -71,6 +86,14 @@ 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);
|
||||||
@@ -83,7 +106,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);
|
applyFont(defaultSettings.fontFamily, defaultSettings.customFonts);
|
||||||
setShowResetConfirm(false);
|
setShowResetConfirm(false);
|
||||||
toast.success("Settings reset to default");
|
toast.success("Settings reset to default");
|
||||||
};
|
};
|
||||||
@@ -99,18 +122,100 @@ 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">
|
||||||
@@ -207,18 +312,39 @@ 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>
|
||||||
<Select value={tempSettings.fontFamily} onValueChange={(value: FontFamily) => setTempSettings((prev) => ({ ...prev, fontFamily: value }))}>
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<SelectTrigger id="font">
|
<Select value={tempSettings.fontFamily} onValueChange={(value: FontFamily) => setTempSettings((prev) => ({ ...prev, fontFamily: value }))}>
|
||||||
<SelectValue placeholder="Select a font"/>
|
<SelectTrigger id="font" className="max-w-full min-w-40">
|
||||||
</SelectTrigger>
|
<SelectValue placeholder="Select a font"/>
|
||||||
<SelectContent>
|
</SelectTrigger>
|
||||||
{FONT_OPTIONS.map((font) => (<SelectItem key={font.value} value={font.value}>
|
<SelectContent>
|
||||||
<span style={{ fontFamily: font.fontFamily }}>
|
{fontOptions.map((font) => {
|
||||||
{font.label}
|
const isCustomFont = font.value.startsWith("custom-");
|
||||||
</span>
|
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) => {
|
||||||
</SelectItem>))}
|
event.preventDefault();
|
||||||
</SelectContent>
|
event.stopPropagation();
|
||||||
</Select>
|
}} onPointerUp={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
}} onClick={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
void handleDeleteCustomFont(font.value as CustomFontFamily);
|
||||||
|
}}>
|
||||||
|
<Trash2 className="h-3.5 w-3.5 text-inherit"/>
|
||||||
|
</Button>) : undefined}>
|
||||||
|
<span style={{ fontFamily: font.fontFamily }}>
|
||||||
|
{font.label}
|
||||||
|
</span>
|
||||||
|
</SelectItem>);
|
||||||
|
})}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button type="button" variant="outline" onClick={() => setShowAddFontDialog(true)} className="shrink-0 gap-1.5">
|
||||||
|
<Plus className="h-4 w-4"/>
|
||||||
|
Add Font
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3 pt-2">
|
<div className="flex items-center gap-3 pt-2">
|
||||||
@@ -240,7 +366,7 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
|||||||
...prev,
|
...prev,
|
||||||
linkResolver: value,
|
linkResolver: value,
|
||||||
}))}>
|
}))}>
|
||||||
<SelectTrigger id="link-resolver" className="h-9 w-fit min-w-[140px]">
|
<SelectTrigger id="link-resolver" className="h-9 w-fit min-w-35">
|
||||||
<SelectValue placeholder="Select a link resolver"/>
|
<SelectValue placeholder="Select a link resolver"/>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -273,8 +399,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 gap-2 flex-wrap">
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
<Select value={tempSettings.downloader} onValueChange={(value: any) => setTempSettings((prev) => ({
|
<Select value={tempSettings.downloader} onValueChange={(value: SettingsType["downloader"]) => setTempSettings((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
downloader: value,
|
downloader: value,
|
||||||
}))}>
|
}))}>
|
||||||
@@ -306,11 +432,11 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
|||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
{tempSettings.downloader === "auto" && (<>
|
{tempSettings.downloader === "auto" && (<>
|
||||||
<Select value={tempSettings.autoOrder || "tidal-qobuz-amazon"} onValueChange={(value: any) => setTempSettings((prev) => ({
|
<Select value={tempSettings.autoOrder || "tidal-qobuz-amazon"} onValueChange={(value: string) => setTempSettings((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
autoOrder: value,
|
autoOrder: value,
|
||||||
}))}>
|
}))}>
|
||||||
<SelectTrigger className="h-9 w-fit min-w-[140px]">
|
<SelectTrigger className="h-9 w-fit min-w-35">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -427,9 +553,7 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
|||||||
</Select>
|
</Select>
|
||||||
</>)}
|
</>)}
|
||||||
|
|
||||||
{tempSettings.downloader === "tidal" && (tempSettings.tidalVariant === "alt" ? (<div className="h-9 px-3 flex items-center text-sm font-medium border border-input rounded-md bg-muted/30 text-muted-foreground whitespace-nowrap cursor-default">
|
{tempSettings.downloader === "tidal" && (<Select value={tempSettings.tidalQuality} onValueChange={handleTidalQualityChange}>
|
||||||
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>
|
||||||
@@ -439,7 +563,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">
|
||||||
@@ -457,27 +581,12 @@ 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,
|
||||||
@@ -485,22 +594,25 @@ 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-6"/>
|
<div className="border-t pt-2"/>
|
||||||
|
|
||||||
<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,
|
||||||
@@ -528,6 +640,15 @@ 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>)}
|
||||||
@@ -645,7 +766,24 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="existing-file-check-mode">Existing File Check</Label>
|
||||||
|
<Select value={tempSettings.existingFileCheckMode} onValueChange={(value: ExistingFileCheckMode) => setTempSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
existingFileCheckMode: value,
|
||||||
|
}))}>
|
||||||
|
<SelectTrigger id="existing-file-check-mode">
|
||||||
|
<SelectValue placeholder="Select existing file check mode"/>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="filename">Filename</SelectItem>
|
||||||
|
<SelectItem value="isrc">ISRC</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Label className="text-sm">Filename Format</Label>
|
<Label className="text-sm">Filename Format</Label>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
@@ -719,20 +857,119 @@ 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
|
||||||
configurations will be lost.
|
font list will be kept.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
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";
|
||||||
@@ -24,7 +27,12 @@ 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("");
|
||||||
@@ -33,6 +41,16 @@ 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;
|
||||||
}) => {
|
}) => {
|
||||||
@@ -88,6 +106,22 @@ 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);
|
||||||
@@ -102,7 +136,17 @@ export function TitleBar() {
|
|||||||
<MenubarTrigger className="cursor-pointer w-8 h-7 p-0 flex items-center justify-center hover:bg-muted transition-colors rounded data-[state=open]:bg-muted">
|
<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-[280px]">
|
<MenubarContent align="end" className="min-w-70">
|
||||||
|
<div className="px-2 py-1.5 space-y-2">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<MenubarLabel className="p-0">Preview Volume</MenubarLabel>
|
||||||
|
<span className="text-xs font-medium text-muted-foreground tabular-nums">
|
||||||
|
{previewVolume}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Slider value={[previewVolume]} min={0} max={100} step={5} onValueChange={handlePreviewVolumeChange} onValueCommit={handlePreviewVolumeCommit} aria-label="Preview volume"/>
|
||||||
|
</div>
|
||||||
|
<MenubarSeparator />
|
||||||
<div className="flex items-center gap-1.5 px-2 py-1.5">
|
<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">
|
||||||
@@ -112,7 +156,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-[18px] rounded-[2px] border object-cover bg-muted"/>) : (<Globe className="w-4 h-4 opacity-70"/>)}
|
{detectedFlagPath ? (<img src={detectedFlagPath} alt={detectedCountryCode} className="h-3.5 w-4.5 rounded-[2px] border object-cover bg-muted"/>) : (<Globe className="w-4 h-4 opacity-70"/>)}
|
||||||
<span className="font-mono text-xs truncate">
|
<span className="font-mono text-xs truncate">
|
||||||
{isLoadingCurrentIPInfo
|
{isLoadingCurrentIPInfo
|
||||||
? "Detecting..."
|
? "Detecting..."
|
||||||
|
|||||||
@@ -37,14 +37,24 @@ 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, ...props }: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
function SelectItem({ className, children, indicatorPosition = "right", trailingAction, ...props }: React.ComponentProps<typeof SelectPrimitive.Item> & {
|
||||||
return (<SelectPrimitive.Item data-slot="select-item" className={cn("focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2", className)} {...props}>
|
indicatorPosition?: "right" | "inline";
|
||||||
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
trailingAction?: React.ReactNode;
|
||||||
<SelectPrimitive.ItemIndicator>
|
}) {
|
||||||
<CheckIcon className="size-4"/>
|
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}>
|
||||||
</SelectPrimitive.ItemIndicator>
|
<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>
|
</span>
|
||||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
{trailingAction ? (<span className="absolute right-2 flex items-center justify-center">
|
||||||
|
{trailingAction}
|
||||||
|
</span>) : indicatorPosition === "right" ? (<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="size-4"/>
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>) : null}
|
||||||
</SelectPrimitive.Item>);
|
</SelectPrimitive.Item>);
|
||||||
}
|
}
|
||||||
function SelectSeparator({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
function SelectSeparator({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import * as SliderPrimitive from "@radix-ui/react-slider";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
function Slider({ className, defaultValue, value, min = 0, max = 100, ...props }: React.ComponentProps<typeof SliderPrimitive.Root>) {
|
||||||
|
const values = Array.isArray(value)
|
||||||
|
? value
|
||||||
|
: Array.isArray(defaultValue)
|
||||||
|
? defaultValue
|
||||||
|
: [min];
|
||||||
|
return (<SliderPrimitive.Root data-slot="slider" defaultValue={defaultValue} value={value} min={min} max={max} className={cn("relative flex w-full touch-none select-none items-center data-[disabled]:opacity-50", className)} {...props}>
|
||||||
|
<SliderPrimitive.Track data-slot="slider-track" className="relative h-2 w-full grow overflow-hidden rounded-full bg-muted">
|
||||||
|
<SliderPrimitive.Range data-slot="slider-range" className="absolute h-full rounded-full bg-primary"/>
|
||||||
|
</SliderPrimitive.Track>
|
||||||
|
{values.map((_, index) => (<SliderPrimitive.Thumb key={index} data-slot="slider-thumb" className="block size-4 shrink-0 rounded-full border-2 border-primary bg-background shadow-sm transition-[color,box-shadow] hover:shadow-md focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-ring/40 disabled:pointer-events-none disabled:opacity-50"/>))}
|
||||||
|
</SliderPrimitive.Root>);
|
||||||
|
}
|
||||||
|
export { Slider };
|
||||||
@@ -36,13 +36,17 @@ 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 || "";
|
||||||
if (!folderTemplate.includes("{isrc}") && !filenameTemplate.includes("{isrc}")) {
|
const shouldResolveISRC = settings.existingFileCheckMode === "isrc" ||
|
||||||
|
folderTemplate.includes("{isrc}") ||
|
||||||
|
filenameTemplate.includes("{isrc}");
|
||||||
|
if (!shouldResolveISRC) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -52,26 +56,18 @@ 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[], settings: any): boolean {
|
function shouldFetchStreamingURLs(order: string[]): boolean {
|
||||||
return order.includes("amazon") || (order.includes("tidal") && !isTidalAltVariant(settings));
|
return order.includes("amazon") || order.includes("tidal");
|
||||||
}
|
}
|
||||||
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);
|
||||||
@@ -83,10 +79,19 @@ 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__";
|
||||||
@@ -189,10 +194,8 @@ 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, settings)) {
|
if (spotifyId && shouldFetchStreamingURLs(order)) {
|
||||||
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);
|
||||||
@@ -209,9 +212,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" && ((tidalVariant === "alt" && spotifyId) || streamingURLs?.tidal_url)) {
|
if (s === "tidal" && streamingURLs?.tidal_url) {
|
||||||
try {
|
try {
|
||||||
logger.debug(`trying ${tidalLabel} for: ${trackName} - ${artistName}`);
|
logger.debug(`trying Tidal for: ${trackName} - ${artistName}`);
|
||||||
const response = await downloadTrack({
|
const response = await downloadTrack({
|
||||||
service: "tidal",
|
service: "tidal",
|
||||||
query,
|
query,
|
||||||
@@ -229,11 +232,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: tidalVariant === "alt" ? undefined : streamingURLs?.tidal_url,
|
service_url: 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,
|
||||||
@@ -246,17 +249,17 @@ export function useDownload(region: string) {
|
|||||||
embed_genre: settings.embedGenre,
|
embed_genre: settings.embedGenre,
|
||||||
});
|
});
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
logger.success(`${tidalLabel}: ${trackName} - ${artistName}`);
|
logger.success(`Tidal: ${trackName} - ${artistName}`);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
const errMsg = response.error || response.message || "Failed";
|
const errMsg = response.error || response.message || "Failed";
|
||||||
fallbackErrors.push(`[${tidalLabel}] ${errMsg}`);
|
fallbackErrors.push(`[Tidal] ${errMsg}`);
|
||||||
lastResponse = response;
|
lastResponse = response;
|
||||||
logger.warning(`${tidalLabel} failed, trying next...`);
|
logger.warning(`Tidal failed, trying next...`);
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
logger.error(`${tidalLabel} error: ${err}`);
|
logger.error(`Tidal error: ${err}`);
|
||||||
fallbackErrors.push(`[${tidalLabel}] ${String(err)}`);
|
fallbackErrors.push(`[Tidal] ${String(err)}`);
|
||||||
lastResponse = { success: false, error: String(err) };
|
lastResponse = { success: false, error: String(err) };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -394,7 +397,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,
|
tidal_api_url: service === "tidal" ? customTidalApi : 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,
|
||||||
@@ -475,10 +478,8 @@ 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, settings)) {
|
if (spotifyId && shouldFetchStreamingURLs(order)) {
|
||||||
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);
|
||||||
@@ -495,9 +496,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" && ((tidalVariant === "alt" && spotifyId) || streamingURLs?.tidal_url)) {
|
if (s === "tidal" && streamingURLs?.tidal_url) {
|
||||||
try {
|
try {
|
||||||
logger.debug(`trying ${tidalLabel} for: ${trackName} - ${artistName}`);
|
logger.debug(`trying Tidal for: ${trackName} - ${artistName}`);
|
||||||
const response = await downloadTrack({
|
const response = await downloadTrack({
|
||||||
service: "tidal",
|
service: "tidal",
|
||||||
query,
|
query,
|
||||||
@@ -515,8 +516,7 @@ 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: tidalVariant === "alt" ? undefined : streamingURLs?.tidal_url,
|
service_url: 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(`${tidalLabel}: ${trackName} - ${artistName}`);
|
logger.success(`Tidal: ${trackName} - ${artistName}`);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
const errMsg = response.error || response.message || "Failed";
|
const errMsg = response.error || response.message || "Failed";
|
||||||
fallbackErrors.push(`[${tidalLabel}] ${errMsg}`);
|
fallbackErrors.push(`[Tidal] ${errMsg}`);
|
||||||
lastResponse = response;
|
lastResponse = response;
|
||||||
logger.warning(`${tidalLabel} failed, trying next...`);
|
logger.warning(`Tidal failed, trying next...`);
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
logger.error(`${tidalLabel} error: ${err}`);
|
logger.error(`Tidal error: ${err}`);
|
||||||
fallbackErrors.push(`[${tidalLabel}] ${String(err)}`);
|
fallbackErrors.push(`[Tidal] ${String(err)}`);
|
||||||
lastResponse = { success: false, error: String(err) };
|
lastResponse = { success: false, error: String(err) };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -679,7 +679,6 @@ 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,
|
||||||
@@ -747,6 +746,8 @@ 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}");
|
||||||
@@ -815,7 +816,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;
|
||||||
setDownloadProgress(Math.round((skippedCount / total) * 100));
|
updateBatchProgress(skippedCount, total);
|
||||||
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.`);
|
||||||
@@ -868,12 +869,13 @@ export function useDownload(region: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const completedCount = skippedCount + successCount + errorCount;
|
const completedCount = skippedCount + successCount + errorCount;
|
||||||
setDownloadProgress(Math.min(100, Math.round((completedCount / total) * 100)));
|
updateBatchProgress(completedCount, total);
|
||||||
}
|
}
|
||||||
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();
|
||||||
@@ -922,6 +924,8 @@ 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}");
|
||||||
@@ -985,7 +989,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;
|
||||||
setDownloadProgress(Math.round((skippedCount / total) * 100));
|
updateBatchProgress(skippedCount, total);
|
||||||
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.`);
|
||||||
@@ -1035,12 +1039,13 @@ 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;
|
||||||
setDownloadProgress(Math.min(100, Math.round((completedCount / total) * 100)));
|
updateBatchProgress(completedCount, total);
|
||||||
}
|
}
|
||||||
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();
|
||||||
@@ -1087,6 +1092,7 @@ export function useDownload(region: string) {
|
|||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
downloadProgress,
|
downloadProgress,
|
||||||
|
downloadRemainingCount,
|
||||||
isDownloading,
|
isDownloading,
|
||||||
downloadingTrack,
|
downloadingTrack,
|
||||||
bulkDownloadType,
|
bulkDownloadType,
|
||||||
|
|||||||
@@ -9,13 +9,17 @@ 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 || "";
|
||||||
if (!folderTemplate.includes("{isrc}") && !filenameTemplate.includes("{isrc}")) {
|
const shouldResolveISRC = settings.existingFileCheckMode === "isrc" ||
|
||||||
|
folderTemplate.includes("{isrc}") ||
|
||||||
|
filenameTemplate.includes("{isrc}");
|
||||||
|
if (!shouldResolveISRC) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,32 +1,34 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { GetPreviewURL } from "@/../wailsjs/go/main/App";
|
import { GetPreviewURL } from "@/../wailsjs/go/main/App";
|
||||||
import { SPOTIFY_PREVIEW_VOLUME } from "@/lib/preview";
|
import { getPreviewVolume } from "@/lib/preview";
|
||||||
|
import { createPreviewPlayback, type PreviewPlayback } from "@/lib/preview-player";
|
||||||
import { toast } from "sonner";
|
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 () => {
|
||||||
if (currentAudio) {
|
stopCurrentAudio();
|
||||||
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) {
|
||||||
currentAudio.pause();
|
stopCurrentAudio();
|
||||||
currentAudio.currentTime = 0;
|
|
||||||
setPlayingTrack(null);
|
setPlayingTrack(null);
|
||||||
setCurrentAudio(null);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (currentAudio) {
|
if (currentAudio) {
|
||||||
currentAudio.pause();
|
stopCurrentAudio();
|
||||||
currentAudio.currentTime = 0;
|
|
||||||
setCurrentAudio(null);
|
|
||||||
setPlayingTrack(null);
|
setPlayingTrack(null);
|
||||||
}
|
}
|
||||||
setLoadingPreview(trackId);
|
setLoadingPreview(trackId);
|
||||||
@@ -38,15 +40,18 @@ export function usePreview() {
|
|||||||
setLoadingPreview(null);
|
setLoadingPreview(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const audio = new Audio(previewURL);
|
const playback = await createPreviewPlayback(previewURL, getPreviewVolume());
|
||||||
audio.volume = SPOTIFY_PREVIEW_VOLUME;
|
const audio = playback.audio;
|
||||||
audio.addEventListener("loadeddata", () => {
|
audio.addEventListener("loadeddata", () => {
|
||||||
setLoadingPreview(null);
|
setLoadingPreview(null);
|
||||||
setPlayingTrack(trackId);
|
setPlayingTrack(trackId);
|
||||||
});
|
});
|
||||||
audio.addEventListener("ended", () => {
|
audio.addEventListener("ended", () => {
|
||||||
setPlayingTrack(null);
|
setPlayingTrack(null);
|
||||||
setCurrentAudio(null);
|
if (currentPlaybackRef.current?.audio === audio) {
|
||||||
|
currentPlaybackRef.current.destroy();
|
||||||
|
currentPlaybackRef.current = null;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
audio.addEventListener("error", () => {
|
audio.addEventListener("error", () => {
|
||||||
toast.error("Failed to play preview", {
|
toast.error("Failed to play preview", {
|
||||||
@@ -54,27 +59,27 @@ export function usePreview() {
|
|||||||
});
|
});
|
||||||
setLoadingPreview(null);
|
setLoadingPreview(null);
|
||||||
setPlayingTrack(null);
|
setPlayingTrack(null);
|
||||||
setCurrentAudio(null);
|
if (currentPlaybackRef.current?.audio === audio) {
|
||||||
|
currentPlaybackRef.current.destroy();
|
||||||
|
currentPlaybackRef.current = null;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
setCurrentAudio(audio);
|
currentPlaybackRef.current = playback;
|
||||||
await audio.play();
|
await audio.play();
|
||||||
}
|
}
|
||||||
catch (error: any) {
|
catch (error: unknown) {
|
||||||
|
stopCurrentAudio();
|
||||||
console.error("Preview error:", error);
|
console.error("Preview error:", error);
|
||||||
toast.error("Preview not available", {
|
toast.error("Preview not available", {
|
||||||
description: error?.message || `Could not load preview for "${trackName}"`,
|
description: error instanceof Error ? error.message : `Could not load preview for "${trackName}"`,
|
||||||
});
|
});
|
||||||
setLoadingPreview(null);
|
setLoadingPreview(null);
|
||||||
setPlayingTrack(null);
|
setPlayingTrack(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const stopPreview = () => {
|
const stopPreview = () => {
|
||||||
if (currentAudio) {
|
stopCurrentAudio();
|
||||||
currentAudio.pause();
|
setPlayingTrack(null);
|
||||||
currentAudio.currentTime = 0;
|
|
||||||
setCurrentAudio(null);
|
|
||||||
setPlayingTrack(null);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
playPreview,
|
playPreview,
|
||||||
|
|||||||
@@ -10,19 +10,10 @@ export interface ApiSource {
|
|||||||
interface SpotiFLACNextSource {
|
interface SpotiFLACNextSource {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
statusKey?: string;
|
||||||
|
statusPrefix?: string;
|
||||||
}
|
}
|
||||||
type SpotiFLACNextStatusResponse = {
|
type SpotiFLACNextStatusResponse = Partial<Record<string, string>>;
|
||||||
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: "" },
|
||||||
@@ -30,13 +21,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" },
|
{ id: "tidal", name: "Tidal", statusKey: "tidal" },
|
||||||
{ id: "qobuz", name: "Qobuz" },
|
{ id: "qobuz", name: "Qobuz", statusPrefix: "qobuz_" },
|
||||||
{ id: "amazon", name: "Amazon Music" },
|
{ id: "amazon", name: "Amazon Music", statusPrefix: "amazon_" },
|
||||||
{ id: "deezer", name: "Deezer" },
|
{ id: "deezer", name: "Deezer", statusPrefix: "deezer_" },
|
||||||
{ id: "apple", name: "Apple Music" },
|
{ id: "apple", name: "Apple Music", statusKey: "apple" },
|
||||||
];
|
];
|
||||||
const SPOTIFLAC_NEXT_STATUS_URL = "https://status.spotbye.qzz.io/status";
|
const SPOTIFLAC_NEXT_STATUS_URL = "https://gist.githubusercontent.com/afkarxyz/6e57cd362cbd67f889e3a91a76254a5e/raw";
|
||||||
const SPOTIFLAC_NEXT_MAX_ATTEMPTS = 3;
|
const SPOTIFLAC_NEXT_MAX_ATTEMPTS = 3;
|
||||||
const SPOTIFLAC_NEXT_RETRY_DELAY_MS = 1200;
|
const SPOTIFLAC_NEXT_RETRY_DELAY_MS = 1200;
|
||||||
type ApiStatusState = {
|
type ApiStatusState = {
|
||||||
@@ -70,12 +61,25 @@ 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));
|
||||||
}
|
}
|
||||||
@@ -98,13 +102,10 @@ 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 {
|
return SPOTIFLAC_NEXT_SOURCES.reduce<Record<string, ApiCheckStatus>>((acc, source) => {
|
||||||
tidal: statusFromNextValue(payload.tidal),
|
acc[source.id] = anyNextVariantUp(getNextSourceValues(payload, source));
|
||||||
qobuz: anyNextVariantUp([payload.qobuz_a, payload.qobuz_b, payload.qobuz_c]),
|
return acc;
|
||||||
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;
|
||||||
|
|||||||
@@ -13,9 +13,6 @@ 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { getPreviewVolume, PREVIEW_VOLUME_CHANGED_EVENT } from "@/lib/preview";
|
||||||
|
export interface PreviewPlayback {
|
||||||
|
audio: HTMLAudioElement;
|
||||||
|
destroy: () => void;
|
||||||
|
}
|
||||||
|
export async function createPreviewPlayback(url: string, volume: number): Promise<PreviewPlayback> {
|
||||||
|
const audio = new Audio(url);
|
||||||
|
const applyVolume = (nextVolume: number) => {
|
||||||
|
if (!Number.isFinite(nextVolume)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
audio.volume = Math.min(1, Math.max(0, nextVolume));
|
||||||
|
};
|
||||||
|
applyVolume(volume);
|
||||||
|
const handleSettingsUpdated = () => {
|
||||||
|
applyVolume(getPreviewVolume());
|
||||||
|
};
|
||||||
|
const handlePreviewVolumeChanged = (event: Event) => {
|
||||||
|
const nextVolumePercent = (event as CustomEvent<number>).detail;
|
||||||
|
if (!Number.isFinite(nextVolumePercent)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
applyVolume(nextVolumePercent / 100);
|
||||||
|
};
|
||||||
|
window.addEventListener("settingsUpdated", handleSettingsUpdated);
|
||||||
|
window.addEventListener(PREVIEW_VOLUME_CHANGED_EVENT, handlePreviewVolumeChanged);
|
||||||
|
return {
|
||||||
|
audio,
|
||||||
|
destroy: () => {
|
||||||
|
window.removeEventListener("settingsUpdated", handleSettingsUpdated);
|
||||||
|
window.removeEventListener(PREVIEW_VOLUME_CHANGED_EVENT, handlePreviewVolumeChanged);
|
||||||
|
audio.pause();
|
||||||
|
audio.removeAttribute("src");
|
||||||
|
audio.load();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1 +1,10 @@
|
|||||||
|
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));
|
||||||
|
}
|
||||||
|
|||||||
+574
-236
@@ -1,15 +1,32 @@
|
|||||||
import { GetDefaults, LoadSettings, SaveSettings as SaveToBackend } from "../../wailsjs/go/main/App";
|
import { GetDefaults, LoadFonts as LoadFontsFromBackend, LoadSettings, SaveFonts as SaveFontsToBackend, SaveSettings as SaveToBackend, } from "../../wailsjs/go/main/App";
|
||||||
export type FontFamily = "google-sans" | "inter" | "poppins" | "roboto" | "dm-sans" | "plus-jakarta-sans" | "manrope" | "space-grotesk" | "noto-sans" | "nunito-sans" | "figtree" | "raleway" | "public-sans" | "outfit" | "jetbrains-mono" | "geist-sans" | "bricolage-grotesque";
|
export type BuiltInFontFamily = "google-sans" | "inter" | "poppins" | "roboto" | "dm-sans" | "plus-jakarta-sans" | "manrope" | "space-grotesk" | "noto-sans" | "nunito-sans" | "figtree" | "raleway" | "public-sans" | "outfit" | "jetbrains-mono" | "geist-sans" | "bricolage-grotesque";
|
||||||
|
export type CustomFontFamily = `custom-${string}`;
|
||||||
|
export type FontFamily = BuiltInFontFamily | CustomFontFamily;
|
||||||
|
export interface CustomFontOption {
|
||||||
|
value: CustomFontFamily;
|
||||||
|
label: string;
|
||||||
|
fontFamily: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
export type FontOption = {
|
||||||
|
value: FontFamily;
|
||||||
|
label: string;
|
||||||
|
fontFamily: string;
|
||||||
|
url?: string;
|
||||||
|
};
|
||||||
export type FolderPreset = "none" | "artist" | "album" | "year-album" | "year-artist-album" | "artist-album" | "artist-year-album" | "artist-year-nested-album" | "album-artist" | "album-artist-album" | "album-artist-year-album" | "album-artist-year-nested-album" | "year" | "year-artist" | "custom";
|
export type 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;
|
||||||
@@ -22,7 +39,6 @@ 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";
|
||||||
@@ -32,6 +48,8 @@ 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;
|
||||||
@@ -42,54 +60,105 @@ 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": { label: "[Year] Artist - Album", template: "[{year}] {artist} - {album}" },
|
"year-artist-album": {
|
||||||
|
label: "[Year] Artist - Album",
|
||||||
|
template: "[{year}] {artist} - {album}",
|
||||||
|
},
|
||||||
"artist-album": { label: "Artist / Album", template: "{artist}/{album}" },
|
"artist-album": { label: "Artist / Album", template: "{artist}/{album}" },
|
||||||
"artist-year-album": { label: "Artist / [Year] Album", template: "{artist}/[{year}] {album}" },
|
"artist-year-album": {
|
||||||
"artist-year-nested-album": { label: "Artist / Year / Album", template: "{artist}/{year}/{album}" },
|
label: "Artist / [Year] Album",
|
||||||
|
template: "{artist}/[{year}] {album}",
|
||||||
|
},
|
||||||
|
"artist-year-nested-album": {
|
||||||
|
label: "Artist / Year / Album",
|
||||||
|
template: "{artist}/{year}/{album}",
|
||||||
|
},
|
||||||
"album-artist": { label: "Album Artist", template: "{album_artist}" },
|
"album-artist": { label: "Album Artist", template: "{album_artist}" },
|
||||||
"album-artist-album": { label: "Album Artist / Album", template: "{album_artist}/{album}" },
|
"album-artist-album": {
|
||||||
"album-artist-year-album": { label: "Album Artist / [Year] Album", template: "{album_artist}/[{year}] {album}" },
|
label: "Album Artist / Album",
|
||||||
"album-artist-year-nested-album": { label: "Album Artist / Year / Album", template: "{album_artist}/{year}/{album}" },
|
template: "{album_artist}/{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": { label: "Track. Title - Artist", template: "{track}. {title} - {artist}" },
|
"track-title-artist": {
|
||||||
"track-artist-title": { label: "Track. Artist - Title", template: "{track}. {artist} - {title}" },
|
label: "Track. Title - Artist",
|
||||||
"title-album-artist": { label: "Title - Album Artist", template: "{title} - {album_artist}" },
|
template: "{track}. {title} - {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-artist-title": {
|
||||||
|
label: "Track. Artist - Title",
|
||||||
|
template: "{track}. {artist} - {title}",
|
||||||
|
},
|
||||||
|
"title-album-artist": {
|
||||||
|
label: "Title - Album Artist",
|
||||||
|
template: "{title} - {album_artist}",
|
||||||
|
},
|
||||||
|
"track-title-album-artist": {
|
||||||
|
label: "Track. Title - Album Artist",
|
||||||
|
template: "{track}. {title} - {album_artist}",
|
||||||
|
},
|
||||||
|
"artist-album-title": {
|
||||||
|
label: "Artist - Album - Title",
|
||||||
|
template: "{artist} - {album} - {title}",
|
||||||
|
},
|
||||||
"track-dash-title": { label: "Track - Title", template: "{track} - {title}" },
|
"track-dash-title": { label: "Track - Title", template: "{track} - {title}" },
|
||||||
"disc-track-title": { label: "Disc-Track. Title", template: "{disc}-{track}. {title}" },
|
"disc-track-title": {
|
||||||
"disc-track-title-artist": { label: "Disc-Track. Title - Artist", template: "{disc}-{track}. {title} - {artist}" },
|
label: "Disc-Track. Title",
|
||||||
"custom": { label: "Custom...", template: "{title} - {artist}" },
|
template: "{disc}-{track}. {title}",
|
||||||
|
},
|
||||||
|
"disc-track-title-artist": {
|
||||||
|
label: "Disc-Track. Title - Artist",
|
||||||
|
template: "{disc}-{track}. {title} - {artist}",
|
||||||
|
},
|
||||||
|
custom: { label: "Custom...", template: "{title} - {artist}" },
|
||||||
};
|
};
|
||||||
export const TEMPLATE_VARIABLES = [
|
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: "{isrc}", description: "Track ISRC", example: "USUM71412345" },
|
key: "{date}",
|
||||||
|
description: "Release date (YYYY-MM-DD)",
|
||||||
|
example: "2014-10-27",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "{isrc}",
|
||||||
|
description: "Track ISRC",
|
||||||
|
example: "USUM71412345",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
function detectOS(): "Windows" | "linux/MacOS" {
|
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";
|
||||||
@@ -97,11 +166,13 @@ 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",
|
||||||
@@ -111,7 +182,6 @@ 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",
|
||||||
@@ -121,42 +191,461 @@ 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: {
|
export const FONT_OPTIONS: FontOption[] = [
|
||||||
value: FontFamily;
|
{
|
||||||
label: string;
|
value: "bricolage-grotesque",
|
||||||
fontFamily: string;
|
label: "Bricolage Grotesque",
|
||||||
}[] = [
|
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: "figtree", label: "Figtree", fontFamily: '"Figtree", system-ui, sans-serif' },
|
value: "dm-sans",
|
||||||
{ value: "geist-sans", label: "Geist Sans", fontFamily: '"Geist", system-ui, sans-serif' },
|
label: "DM Sans",
|
||||||
{ value: "google-sans", label: "Google Sans", fontFamily: '"Google Sans", system-ui, sans-serif' },
|
fontFamily: '"DM 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: "figtree",
|
||||||
{ value: "noto-sans", label: "Noto Sans", fontFamily: '"Noto Sans", system-ui, sans-serif' },
|
label: "Figtree",
|
||||||
{ value: "nunito-sans", label: "Nunito Sans", fontFamily: '"Nunito Sans", system-ui, sans-serif' },
|
fontFamily: '"Figtree", 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: "geist-sans",
|
||||||
{ value: "public-sans", label: "Public Sans", fontFamily: '"Public Sans", system-ui, sans-serif' },
|
label: "Geist Sans",
|
||||||
{ value: "raleway", label: "Raleway", fontFamily: '"Raleway", system-ui, sans-serif' },
|
fontFamily: '"Geist", 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',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
export function applyFont(fontFamily: FontFamily): void {
|
const BUILT_IN_FONT_VALUES = new Set(FONT_OPTIONS.map((font) => font.value));
|
||||||
const font = FONT_OPTIONS.find(f => f.value === fontFamily);
|
const GOOGLE_FONT_LINK_ID_PREFIX = "spotiflac-custom-font-";
|
||||||
|
const GOOGLE_FONTS_CSS_HOST = "fonts.googleapis.com";
|
||||||
|
const GOOGLE_FONTS_SPECIMEN_HOST = "fonts.google.com";
|
||||||
|
const SETTINGS_KEY = "spotiflac-settings";
|
||||||
|
let cachedSettings: Settings | null = null;
|
||||||
|
type SettingsPayload = Partial<Settings> & {
|
||||||
|
darkMode?: boolean;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
const KNOWN_SETTINGS_KEYS = Object.keys(DEFAULT_SETTINGS) as Array<keyof Settings>;
|
||||||
|
function extractGoogleFontInputUrl(input: string): string {
|
||||||
|
const trimmed = input.trim();
|
||||||
|
const hrefMatch = trimmed.match(/\bhref=["']([^"']+)["']/i);
|
||||||
|
if (hrefMatch?.[1]) {
|
||||||
|
return hrefMatch[1];
|
||||||
|
}
|
||||||
|
const importMatch = trimmed.match(/@import\s+url\(["']?([^"')]+)["']?\)/i);
|
||||||
|
if (importMatch?.[1]) {
|
||||||
|
return importMatch[1];
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
function coerceGoogleFontUrl(rawUrl: string): string {
|
||||||
|
const trimmed = rawUrl.trim();
|
||||||
|
if (/^https?:\/\//i.test(trimmed)) {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
if (/^(fonts\.googleapis\.com|fonts\.google\.com)\//i.test(trimmed)) {
|
||||||
|
return `https://${trimmed}`;
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
function normalizeFontLabel(label: string): string {
|
||||||
|
return label.replace(/\+/g, " ").replace(/\s+/g, " ").trim();
|
||||||
|
}
|
||||||
|
function slugifyFontLabel(label: string): string {
|
||||||
|
return label.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "font";
|
||||||
|
}
|
||||||
|
function toFontFamilyCss(label: string): string {
|
||||||
|
const escapedLabel = label.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
||||||
|
return `"${escapedLabel}", system-ui, sans-serif`;
|
||||||
|
}
|
||||||
|
function buildGoogleFontsCssUrl(label: string): string {
|
||||||
|
const url = new URL("https://fonts.googleapis.com/css2");
|
||||||
|
url.searchParams.set("family", label);
|
||||||
|
url.searchParams.set("display", "swap");
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
function extractSpecimenFontLabel(parsed: URL): string {
|
||||||
|
const segments = parsed.pathname.split("/").filter(Boolean);
|
||||||
|
const specimenIndex = segments.findIndex((segment) => segment.toLowerCase() === "specimen");
|
||||||
|
const specimenName = specimenIndex >= 0 ? segments[specimenIndex + 1] : "";
|
||||||
|
return normalizeFontLabel(decodeURIComponent(specimenName || ""));
|
||||||
|
}
|
||||||
|
function normalizeGoogleFontCssUrl(rawUrl: string): string | null {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(coerceGoogleFontUrl(extractGoogleFontInputUrl(rawUrl)));
|
||||||
|
if (parsed.protocol !== "https:") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (parsed.hostname === GOOGLE_FONTS_SPECIMEN_HOST) {
|
||||||
|
const label = extractSpecimenFontLabel(parsed);
|
||||||
|
return label ? buildGoogleFontsCssUrl(label) : null;
|
||||||
|
}
|
||||||
|
if (parsed.hostname !== GOOGLE_FONTS_CSS_HOST ||
|
||||||
|
(parsed.pathname !== "/css" && parsed.pathname !== "/css2")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (parsed.searchParams.getAll("family").length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!parsed.searchParams.has("display")) {
|
||||||
|
parsed.searchParams.set("display", "swap");
|
||||||
|
}
|
||||||
|
return parsed.toString();
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export function parseGoogleFontUrl(rawUrl: string): CustomFontOption | null {
|
||||||
|
const normalizedUrl = normalizeGoogleFontCssUrl(rawUrl);
|
||||||
|
if (!normalizedUrl) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const parsed = new URL(normalizedUrl);
|
||||||
|
const family = parsed.searchParams.getAll("family")[0];
|
||||||
|
const label = normalizeFontLabel((family || "").split(":")[0] || "");
|
||||||
|
if (!label) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
value: `custom-${slugifyFontLabel(label)}` as CustomFontFamily,
|
||||||
|
label,
|
||||||
|
fontFamily: toFontFamilyCss(label),
|
||||||
|
url: normalizedUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function normalizeCustomFonts(customFonts: unknown): CustomFontOption[] {
|
||||||
|
if (!Array.isArray(customFonts)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const normalizedFonts: CustomFontOption[] = [];
|
||||||
|
const seenValues = new Set<string>();
|
||||||
|
const seenUrls = new Set<string>();
|
||||||
|
for (const item of customFonts) {
|
||||||
|
if (!item || typeof item !== "object") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const rawUrl = (item as {
|
||||||
|
url?: unknown;
|
||||||
|
}).url;
|
||||||
|
if (typeof rawUrl !== "string") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const parsed = parseGoogleFontUrl(rawUrl);
|
||||||
|
if (!parsed || seenValues.has(parsed.value) || seenUrls.has(parsed.url)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seenValues.add(parsed.value);
|
||||||
|
seenUrls.add(parsed.url);
|
||||||
|
normalizedFonts.push(parsed);
|
||||||
|
}
|
||||||
|
return normalizedFonts;
|
||||||
|
}
|
||||||
|
function normalizeFontFamily(fontFamily: unknown, customFonts: CustomFontOption[]): FontFamily {
|
||||||
|
if (typeof fontFamily !== "string") {
|
||||||
|
return DEFAULT_SETTINGS.fontFamily;
|
||||||
|
}
|
||||||
|
if (BUILT_IN_FONT_VALUES.has(fontFamily as BuiltInFontFamily)) {
|
||||||
|
return fontFamily as BuiltInFontFamily;
|
||||||
|
}
|
||||||
|
const customFont = customFonts.find((font) => font.value === fontFamily);
|
||||||
|
return customFont ? customFont.value : DEFAULT_SETTINGS.fontFamily;
|
||||||
|
}
|
||||||
|
export function getFontOptions(customFonts: CustomFontOption[] = []): FontOption[] {
|
||||||
|
return [...FONT_OPTIONS, ...normalizeCustomFonts(customFonts)];
|
||||||
|
}
|
||||||
|
export function loadGoogleFontUrl(url: string, id = `${GOOGLE_FONT_LINK_ID_PREFIX}preview`): void {
|
||||||
|
const normalizedUrl = normalizeGoogleFontCssUrl(url);
|
||||||
|
if (!normalizedUrl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let link = document.getElementById(id) as HTMLLinkElement | null;
|
||||||
|
if (!link) {
|
||||||
|
link = document.createElement("link");
|
||||||
|
link.id = id;
|
||||||
|
link.rel = "stylesheet";
|
||||||
|
document.head.appendChild(link);
|
||||||
|
}
|
||||||
|
if (link.href !== normalizedUrl) {
|
||||||
|
link.href = normalizedUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function loadCustomFontStylesheets(customFonts: CustomFontOption[]): void {
|
||||||
|
for (const font of normalizeCustomFonts(customFonts)) {
|
||||||
|
loadGoogleFontUrl(font.url, `${GOOGLE_FONT_LINK_ID_PREFIX}${font.value}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export function applyFont(fontFamily: FontFamily, customFonts: CustomFontOption[] = []): void {
|
||||||
|
const fontOptions = getFontOptions(customFonts);
|
||||||
|
loadCustomFontStylesheets(customFonts);
|
||||||
|
const font = fontOptions.find((option) => option.value === fontFamily) ||
|
||||||
|
FONT_OPTIONS.find((option) => option.value === DEFAULT_SETTINGS.fontFamily);
|
||||||
if (font) {
|
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();
|
||||||
@@ -167,90 +656,11 @@ 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) {
|
||||||
const parsed = JSON.parse(stored);
|
return toNormalizedSettings(JSON.parse(stored) as SettingsPayload);
|
||||||
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) {
|
||||||
@@ -259,108 +669,25 @@ 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 any;
|
const parsed = backendSettings as SettingsPayload;
|
||||||
if ('darkMode' in parsed && !('themeMode' in parsed)) {
|
const customFonts = await loadStoredCustomFonts(parsed.customFonts);
|
||||||
parsed.themeMode = parsed.darkMode ? 'dark' : 'light';
|
cachedSettings = toNormalizedSettings({
|
||||||
delete parsed.darkMode;
|
...parsed,
|
||||||
|
customFonts,
|
||||||
|
});
|
||||||
|
if ("customFonts" in parsed) {
|
||||||
|
await persistSettingsInternal(cachedSettings, false);
|
||||||
}
|
}
|
||||||
if (!('folderPreset' in parsed) && ('artistSubfolder' in parsed || 'albumSubfolder' in parsed)) {
|
return cachedSettings;
|
||||||
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) {
|
||||||
@@ -368,12 +695,19 @@ export async function loadSettings(): Promise<Settings> {
|
|||||||
}
|
}
|
||||||
const local = getSettingsFromLocalStorage();
|
const local = getSettingsFromLocalStorage();
|
||||||
try {
|
try {
|
||||||
await SaveToBackend(local as any);
|
const customFonts = await loadStoredCustomFonts(local.customFonts);
|
||||||
cachedSettings = local;
|
const localWithFonts = toNormalizedSettings({
|
||||||
|
...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 {
|
||||||
@@ -389,8 +723,9 @@ 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");
|
||||||
@@ -414,10 +749,8 @@ export async function getSettingsWithDefaults(): Promise<Settings> {
|
|||||||
}
|
}
|
||||||
export async function saveSettings(settings: Settings): Promise<void> {
|
export async function saveSettings(settings: Settings): Promise<void> {
|
||||||
try {
|
try {
|
||||||
cachedSettings = settings;
|
const normalizedSettings = toNormalizedSettings(settings as SettingsPayload);
|
||||||
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
|
await persistSettingsInternal(normalizedSettings);
|
||||||
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);
|
||||||
@@ -431,7 +764,12 @@ 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 defaultSettings = { ...DEFAULT_SETTINGS, downloadPath: defaultPath };
|
const customFonts = await loadCustomFonts();
|
||||||
|
const defaultSettings = {
|
||||||
|
...DEFAULT_SETTINGS,
|
||||||
|
downloadPath: defaultPath,
|
||||||
|
customFonts,
|
||||||
|
};
|
||||||
await saveSettings(defaultSettings);
|
await saveSettings(defaultSettings);
|
||||||
return defaultSettings;
|
return defaultSettings;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,7 +120,6 @@ 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;
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@
|
|||||||
},
|
},
|
||||||
"info": {
|
"info": {
|
||||||
"productName": "SpotiFLAC",
|
"productName": "SpotiFLAC",
|
||||||
"productVersion": "7.1.5",
|
"productVersion": "7.1.6",
|
||||||
"copyright": "© 2026 afkarxyz"
|
"copyright": "© 2026 afkarxyz"
|
||||||
},
|
},
|
||||||
"wailsjsdir": "./frontend",
|
"wailsjsdir": "./frontend",
|
||||||
|
|||||||
Reference in New Issue
Block a user