diff --git a/app.go b/app.go index 97f33f2..bde6d3e 100644 --- a/app.go +++ b/app.go @@ -292,7 +292,7 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) { downloader := backend.NewAmazonDownloader() if req.ServiceURL != "" { - filename, err = downloader.DownloadByURL(req.ServiceURL, req.OutputDir, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.CoverURL, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.EmbedMaxQualityCover, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL) + filename, err = downloader.DownloadByURL(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.CoverURL, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.EmbedMaxQualityCover, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL) } else { if req.SpotifyID == "" { return DownloadResponse{ @@ -300,7 +300,7 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) { Error: "Spotify ID is required for Amazon Music", }, fmt.Errorf("spotify ID is required for Amazon Music") } - filename, err = downloader.DownloadBySpotifyID(req.SpotifyID, req.OutputDir, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.CoverURL, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.EmbedMaxQualityCover, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL) + filename, err = downloader.DownloadBySpotifyID(req.SpotifyID, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.CoverURL, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.EmbedMaxQualityCover, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL) } case "tidal": diff --git a/backend/amazon.go b/backend/amazon.go index 0799231..d620f27 100644 --- a/backend/amazon.go +++ b/backend/amazon.go @@ -1,12 +1,15 @@ package backend import ( + "bytes" + "crypto/tls" "encoding/base64" "encoding/json" "fmt" "io" "math/rand" "net/http" + "net/http/cookiejar" "net/url" "os" "path/filepath" @@ -44,6 +47,22 @@ type DoubleDoubleStatusResponse struct { } `json:"current"` } +type LucidaLoadResponse struct { + Success bool `json:"success"` + Server string `json:"server"` + Handoff string `json:"handoff"` + Error string `json:"error"` +} + +type LucidaStatusResponse struct { + Status string `json:"status"` + Message string `json:"message"` + Progress struct { + Current int64 `json:"current"` + Total int64 `json:"total"` + } `json:"progress"` +} + func NewAmazonDownloader() *AmazonDownloader { return &AmazonDownloader{ client: &http.Client{ @@ -175,8 +194,193 @@ func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (strin return amazonURL, nil } -func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir string) (string, error) { +func (a *AmazonDownloader) extractData(html string, patterns []string) string { + for _, p := range patterns { + re := regexp.MustCompile(p) + matches := re.FindStringSubmatch(html) + if len(matches) > 1 { + return matches[1] + } + } + return "" +} + +func (a *AmazonDownloader) DownloadFromLucida(amazonURL, outputDir, quality string) (string, error) { + tr := &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + } + jar, _ := cookiejar.New(nil) + client := &http.Client{ + Transport: tr, + Jar: jar, + Timeout: 120 * time.Second, + } + + userAgent := a.getRandomUserAgent() + + fmt.Printf("Initializing lucida for Amazon Music... (Target: %s)\n", amazonURL) + lucidaBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9sdWNpZGEudG8vP3VybD0lcyZjb3VudHJ5PWF1dG8=") + lucidaURL := fmt.Sprintf(string(lucidaBase), url.QueryEscape(amazonURL)) + req, _ := http.NewRequest("GET", lucidaURL, nil) + req.Header.Set("User-Agent", userAgent) + + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + bodyBytes, _ := io.ReadAll(resp.Body) + html := string(bodyBytes) + + token := a.extractData(html, []string{`token:"([^"]+)"`, `"token"\s*:\s*"([^"]+)"`}) + streamURL := a.extractData(html, []string{`"url":"([^"]+)"`, `url:"([^"]+)"`}) + expiry := a.extractData(html, []string{`tokenExpiry:(\d+)`, `"tokenExpiry"\s*:\s*(\d+)`}) + + if token == "" || streamURL == "" { + errorMsg := a.extractData(html, []string{`error:"([^"]+)"`, `"error"\s*:\s*"([^"]+)"`}) + if errorMsg != "" { + return "", fmt.Errorf("lucida error: %s", errorMsg) + } + return "", fmt.Errorf("could not extract required data from lucida") + } + + decodedToken := token + if secondBase64, err := base64.StdEncoding.DecodeString(token); err == nil { + if firstBase64, err := base64.StdEncoding.DecodeString(string(secondBase64)); err == nil { + decodedToken = string(firstBase64) + } + } + + streamURL = strings.ReplaceAll(streamURL, `\/`, `/`) + fmt.Printf("Fetching Amazon stream via Lucida...\n") + + loadPayload := map[string]interface{}{ + "account": map[string]string{"id": "auto", "type": "country"}, + "compat": "false", "downscale": "original", "handoff": true, + "metadata": true, "private": true, + "token": map[string]interface{}{"primary": decodedToken, "expiry": expiry}, + "upload": map[string]bool{"enabled": false}, + "url": streamURL, + } + + payloadBytes, _ := json.Marshal(loadPayload) + loadAPI, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9sdWNpZGEudG8vYXBpL2xvYWQ/dXJsPS9hcGkvZmV0Y2gvc3RyZWFtL3Yy") + req, _ = http.NewRequest("POST", string(loadAPI), bytes.NewBuffer(payloadBytes)) + req.Header.Set("User-Agent", userAgent) + req.Header.Set("Content-Type", "application/json") + + for _, cookie := range client.Jar.Cookies(req.URL) { + if cookie.Name == "csrf_token" { + req.Header.Set("X-CSRF-Token", cookie.Value) + } + } + + resp, err = client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + var loadData LucidaLoadResponse + json.NewDecoder(resp.Body).Decode(&loadData) + + if !loadData.Success { + return "", fmt.Errorf("lucida load request failed: %s", loadData.Error) + } + + serviceBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly8=") + completionBase, _ := base64.StdEncoding.DecodeString("Lmx1Y2lkYS50by9hcGkvZmV0Y2gvcmVxdWVzdC8=") + completionURL := fmt.Sprintf("%s%s%s%s", string(serviceBase), loadData.Server, string(completionBase), loadData.Handoff) + fmt.Println("Processing on Lucida server...") + + var finalStatus LucidaStatusResponse + for { + req, _ = http.NewRequest("GET", completionURL, nil) + req.Header.Set("User-Agent", userAgent) + resp, err = client.Do(req) + if err != nil { + return "", err + } + + json.NewDecoder(resp.Body).Decode(&finalStatus) + resp.Body.Close() + + if finalStatus.Status == "completed" { + fmt.Println("\nTrack processing completed!") + break + } else if finalStatus.Status == "error" { + return "", fmt.Errorf("lucida processing failed: %s", finalStatus.Message) + } else if finalStatus.Progress.Total > 0 { + percent := (finalStatus.Progress.Current * 100) / finalStatus.Progress.Total + fmt.Printf("\rLucida Progress: %d%%", percent) + } + time.Sleep(2 * time.Second) + } + + downloadSuffix, _ := base64.StdEncoding.DecodeString("L2Rvd25sb2Fk") + downloadURL := fmt.Sprintf("%s%s%s%s%s", string(serviceBase), loadData.Server, string(completionBase), loadData.Handoff, string(downloadSuffix)) + req, _ = http.NewRequest("GET", downloadURL, nil) + req.Header.Set("User-Agent", userAgent) + resp, err = client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return "", fmt.Errorf("lucida download failed with status %d", resp.StatusCode) + } + + fileName := "track.flac" + contentDisp := resp.Header.Get("Content-Disposition") + if contentDisp != "" { + re := regexp.MustCompile(`filename[*]?=([^;]+)`) + if matches := re.FindStringSubmatch(contentDisp); len(matches) > 1 { + rawName := strings.Trim(matches[1], `"'`) + if strings.HasPrefix(rawName, "UTF-8''") { + decodedName, _ := url.PathUnescape(rawName[7:]) + fileName = decodedName + } else { + fileName = rawName + } + + reg := regexp.MustCompile(`[<>:"/\\|?*]`) + fileName = reg.ReplaceAllString(fileName, "") + } + } + + filePath := filepath.Join(outputDir, fileName) + out, err := os.Create(filePath) + if err != nil { + return "", err + } + defer out.Close() + + fmt.Printf("Downloading from Lucida: %s\n", fileName) + + pw := NewProgressWriter(out) + _, err = io.Copy(pw, resp.Body) + if err != nil { + out.Close() + os.Remove(filePath) + return "", fmt.Errorf("failed to write file: %w", err) + } + + fmt.Printf("\rDownloaded: %.2f MB (Complete)\n", float64(pw.GetTotal())/(1024*1024)) + return filePath, nil +} + +func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir, quality string) (string, error) { + fmt.Println("Attempting download via Lucida (Priority)...") + filePath, err := a.DownloadFromLucida(amazonURL, outputDir, quality) + if err == nil { + return filePath, nil + } + fmt.Printf("Lucida failed: %v\nTrying Double-Double as fallback...\n", err) + var lastError error + lastError = err for _, region := range a.regions { fmt.Printf("\nTrying region: %s...\n", region) @@ -357,7 +561,7 @@ func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir string) (str return "", fmt.Errorf("all regions failed. Last error: %v", lastError) } -func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover bool, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string) (string, error) { +func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover bool, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string) (string, error) { if outputDir != "." { if err := os.MkdirAll(outputDir, 0755); err != nil { @@ -377,7 +581,7 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, filenameFormat st fmt.Printf("Using Amazon URL: %s\n", amazonURL) - filePath, err := a.DownloadFromService(amazonURL, outputDir) + filePath, err := a.DownloadFromService(amazonURL, outputDir, quality) if err != nil { return "", err } @@ -492,12 +696,12 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, filenameFormat st return filePath, nil } -func (a *AmazonDownloader) DownloadBySpotifyID(spotifyTrackID, outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover bool, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string) (string, error) { +func (a *AmazonDownloader) DownloadBySpotifyID(spotifyTrackID, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover bool, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string) (string, error) { amazonURL, err := a.GetAmazonURLFromSpotify(spotifyTrackID) if err != nil { return "", err } - return a.DownloadByURL(amazonURL, outputDir, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, embedMaxQualityCover, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyURL) + return a.DownloadByURL(amazonURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, embedMaxQualityCover, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyURL) } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c1175a6..8aa7649 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -253,11 +253,11 @@ function App() { return null; if ("track" in metadata.metadata) { const { track } = metadata.metadata; - return ( lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, undefined, undefined, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onDownloadCover={(coverUrl, trackName, artistName, albumName, _playlistName, _position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, undefined, undefined, trackId, albumArtist, releaseDate, discNumber)} onOpenFolder={handleOpenFolder}/>); + return ( lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, track.album_name, undefined, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _playlistName, _position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, track.album_name, undefined, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onOpenFolder={handleOpenFolder}/>); } if ("album_info" in metadata.metadata) { const { album_info, track_list } = metadata.metadata; - return ( 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, undefined, true)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, undefined, true)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onPageChange={setCurrentListPage} onArtistClick={async (artist) => { + return ( 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} onArtistClick={async (artist) => { const artistUrl = await metadata.handleArtistClick(artist); if (artistUrl) { setSpotifyUrl(artistUrl); @@ -271,7 +271,7 @@ function App() { } if ("playlist_info" in metadata.metadata) { const { playlist_info, track_list } = metadata.metadata; - return ( lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, playlist_info.owner.name, position, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, playlist_info.owner.name, position, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, playlist_info.owner.name)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, playlist_info.owner.name)} onDownloadAll={() => download.handleDownloadAll(track_list, playlist_info.owner.name)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, playlist_info.owner.name)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onPageChange={setCurrentListPage} onAlbumClick={metadata.handleAlbumClick} onArtistClick={async (artist) => { + return ( lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, playlist_info.name, position, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, playlist_info.name, position, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, playlist_info.name)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, playlist_info.name)} onDownloadAll={() => download.handleDownloadAll(track_list, playlist_info.name)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, playlist_info.name)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onPageChange={setCurrentListPage} onAlbumClick={metadata.handleAlbumClick} onArtistClick={async (artist) => { const artistUrl = await metadata.handleArtistClick(artist); if (artistUrl) { setSpotifyUrl(artistUrl); diff --git a/frontend/src/components/SettingsPage.tsx b/frontend/src/components/SettingsPage.tsx index e2210f1..a24be09 100644 --- a/frontend/src/components/SettingsPage.tsx +++ b/frontend/src/components/SettingsPage.tsx @@ -27,140 +27,140 @@ const AmazonIcon = () => ( ); interface SettingsPageProps { - onUnsavedChangesChange?: (hasUnsavedChanges: boolean) => void; - onResetRequest?: (resetFn: () => void) => void; + onUnsavedChangesChange?: (hasUnsavedChanges: boolean) => void; + onResetRequest?: (resetFn: () => void) => void; } export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: SettingsPageProps) { - const [savedSettings, setSavedSettings] = useState(getSettings()); - const [tempSettings, setTempSettings] = useState(savedSettings); - const [isDark, setIsDark] = useState(document.documentElement.classList.contains('dark')); - const [showResetConfirm, setShowResetConfirm] = useState(false); - const [showFFmpegWarning, setShowFFmpegWarning] = useState(false); - const [isInstallingFFmpeg, setIsInstallingFFmpeg] = useState(false); - const [installProgress, setInstallProgress] = useState(0); - const downloadProgress = useDownloadProgress(); - const hasUnsavedChanges = JSON.stringify(savedSettings) !== JSON.stringify(tempSettings); - const resetToSaved = useCallback(() => { - const freshSavedSettings = getSettings(); - flushSync(() => { - setTempSettings(freshSavedSettings); - setIsDark(document.documentElement.classList.contains('dark')); - }); - }, []); - useEffect(() => { - if (onResetRequest) { - onResetRequest(resetToSaved); - } - }, [onResetRequest, resetToSaved]); - useEffect(() => { - onUnsavedChangesChange?.(hasUnsavedChanges); - }, [hasUnsavedChanges, onUnsavedChangesChange]); - useEffect(() => { - applyThemeMode(savedSettings.themeMode); + const [savedSettings, setSavedSettings] = useState(getSettings()); + const [tempSettings, setTempSettings] = useState(savedSettings); + const [isDark, setIsDark] = useState(document.documentElement.classList.contains('dark')); + const [showResetConfirm, setShowResetConfirm] = useState(false); + const [showFFmpegWarning, setShowFFmpegWarning] = useState(false); + const [isInstallingFFmpeg, setIsInstallingFFmpeg] = useState(false); + const [installProgress, setInstallProgress] = useState(0); + const downloadProgress = useDownloadProgress(); + const hasUnsavedChanges = JSON.stringify(savedSettings) !== JSON.stringify(tempSettings); + const resetToSaved = useCallback(() => { + const freshSavedSettings = getSettings(); + flushSync(() => { + setTempSettings(freshSavedSettings); + setIsDark(document.documentElement.classList.contains('dark')); + }); + }, []); + useEffect(() => { + if (onResetRequest) { + onResetRequest(resetToSaved); + } + }, [onResetRequest, resetToSaved]); + useEffect(() => { + onUnsavedChangesChange?.(hasUnsavedChanges); + }, [hasUnsavedChanges, onUnsavedChangesChange]); + useEffect(() => { + applyThemeMode(savedSettings.themeMode); + applyTheme(savedSettings.theme); + const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); + const handleChange = () => { + if (savedSettings.themeMode === "auto") { + applyThemeMode("auto"); applyTheme(savedSettings.theme); - const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); - const handleChange = () => { - if (savedSettings.themeMode === "auto") { - applyThemeMode("auto"); - applyTheme(savedSettings.theme); - } - }; - mediaQuery.addEventListener("change", handleChange); - return () => mediaQuery.removeEventListener("change", handleChange); - }, [savedSettings.themeMode, savedSettings.theme]); - useEffect(() => { - applyThemeMode(tempSettings.themeMode); - applyTheme(tempSettings.theme); - applyFont(tempSettings.fontFamily); - setTimeout(() => { - setIsDark(document.documentElement.classList.contains('dark')); - }, 0); - }, [tempSettings.themeMode, tempSettings.theme, tempSettings.fontFamily]); - useEffect(() => { - const loadDefaults = async () => { - if (!savedSettings.downloadPath) { - const settingsWithDefaults = await getSettingsWithDefaults(); - setSavedSettings(settingsWithDefaults); - setTempSettings(settingsWithDefaults); - await saveSettings(settingsWithDefaults); - } - }; - loadDefaults(); - }, []); - const handleSave = async () => { - await saveSettings(tempSettings); - setSavedSettings(tempSettings); - toast.success("Settings saved"); - onUnsavedChangesChange?.(false); + } }; - const handleReset = async () => { - const defaultSettings = await resetToDefaultSettings(); - setTempSettings(defaultSettings); - setSavedSettings(defaultSettings); - applyThemeMode(defaultSettings.themeMode); - applyTheme(defaultSettings.theme); - applyFont(defaultSettings.fontFamily); - setShowResetConfirm(false); - toast.success("Settings reset to default"); + mediaQuery.addEventListener("change", handleChange); + return () => mediaQuery.removeEventListener("change", handleChange); + }, [savedSettings.themeMode, savedSettings.theme]); + useEffect(() => { + applyThemeMode(tempSettings.themeMode); + applyTheme(tempSettings.theme); + applyFont(tempSettings.fontFamily); + setTimeout(() => { + setIsDark(document.documentElement.classList.contains('dark')); + }, 0); + }, [tempSettings.themeMode, tempSettings.theme, tempSettings.fontFamily]); + useEffect(() => { + const loadDefaults = async () => { + if (!savedSettings.downloadPath) { + const settingsWithDefaults = await getSettingsWithDefaults(); + setSavedSettings(settingsWithDefaults); + setTempSettings(settingsWithDefaults); + await saveSettings(settingsWithDefaults); + } }; - const handleBrowseFolder = async () => { - try { - const selectedPath = await SelectFolder(tempSettings.downloadPath || ""); - if (selectedPath && selectedPath.trim() !== "") { - setTempSettings((prev) => ({ ...prev, downloadPath: selectedPath })); - } + loadDefaults(); + }, []); + const handleSave = async () => { + await saveSettings(tempSettings); + setSavedSettings(tempSettings); + toast.success("Settings saved"); + onUnsavedChangesChange?.(false); + }; + const handleReset = async () => { + const defaultSettings = await resetToDefaultSettings(); + setTempSettings(defaultSettings); + setSavedSettings(defaultSettings); + applyThemeMode(defaultSettings.themeMode); + applyTheme(defaultSettings.theme); + applyFont(defaultSettings.fontFamily); + setShowResetConfirm(false); + toast.success("Settings reset to default"); + }; + const handleBrowseFolder = async () => { + try { + const selectedPath = await SelectFolder(tempSettings.downloadPath || ""); + if (selectedPath && selectedPath.trim() !== "") { + setTempSettings((prev) => ({ ...prev, downloadPath: selectedPath })); + } + } + catch (error) { + console.error("Error selecting folder:", error); + toast.error(`Error selecting folder: ${error}`); + } + }; + const handleTidalQualityChange = async (value: "LOSSLESS" | "HI_RES_LOSSLESS") => { + if (value === "HI_RES_LOSSLESS") { + try { + const { CheckFFmpegInstalled } = await import("../../wailsjs/go/main/App"); + const isInstalled = await CheckFFmpegInstalled(); + if (!isInstalled) { + setShowFFmpegWarning(true); + return; } - catch (error) { - console.error("Error selecting folder:", error); - toast.error(`Error selecting folder: ${error}`); - } - }; - const handleTidalQualityChange = async (value: "LOSSLESS" | "HI_RES_LOSSLESS") => { - if (value === "HI_RES_LOSSLESS") { - try { - const { CheckFFmpegInstalled } = await import("../../wailsjs/go/main/App"); - const isInstalled = await CheckFFmpegInstalled(); - if (!isInstalled) { - setShowFFmpegWarning(true); - return; - } - } - catch (error) { - console.error("Error checking FFmpeg:", error); - } - } - setTempSettings((prev) => ({ ...prev, tidalQuality: value })); - }; - const handleInstallFFmpeg = async () => { - setIsInstallingFFmpeg(true); - setInstallProgress(0); - try { - const { DownloadFFmpeg } = await import("../../wailsjs/go/main/App"); - const { EventsOn, EventsOff } = await import("../../wailsjs/runtime/runtime"); - EventsOn("ffmpeg:progress", (progress: number) => { - setInstallProgress(progress); - }); - const response = await DownloadFFmpeg(); - EventsOff("ffmpeg:progress"); - if (response.success) { - toast.success("FFmpeg installed successfully!"); - setShowFFmpegWarning(false); - setTempSettings((prev) => ({ ...prev, tidalQuality: "HI_RES_LOSSLESS" })); - } - else { - toast.error(`Failed to install FFmpeg: ${response.error}`); - } - } - catch (error) { - console.error("Error installing FFmpeg:", error); - toast.error(`Error during FFmpeg installation: ${error}`); - } - finally { - setIsInstallingFFmpeg(false); - setInstallProgress(0); - } - }; - return (
+ } + catch (error) { + console.error("Error checking FFmpeg:", error); + } + } + setTempSettings((prev) => ({ ...prev, tidalQuality: value })); + }; + const handleInstallFFmpeg = async () => { + setIsInstallingFFmpeg(true); + setInstallProgress(0); + try { + const { DownloadFFmpeg } = await import("../../wailsjs/go/main/App"); + const { EventsOn, EventsOff } = await import("../../wailsjs/runtime/runtime"); + EventsOn("ffmpeg:progress", (progress: number) => { + setInstallProgress(progress); + }); + const response = await DownloadFFmpeg(); + EventsOff("ffmpeg:progress"); + if (response.success) { + toast.success("FFmpeg installed successfully!"); + setShowFFmpegWarning(false); + setTempSettings((prev) => ({ ...prev, tidalQuality: "HI_RES_LOSSLESS" })); + } + else { + toast.error(`Failed to install FFmpeg: ${response.error}`); + } + } + catch (error) { + console.error("Error installing FFmpeg:", error); + toast.error(`Error during FFmpeg installation: ${error}`); + } + finally { + setIsInstallingFFmpeg(false); + setInstallProgress(0); + } + }; + return (

Settings

@@ -170,9 +170,9 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
- setTempSettings((prev) => ({ ...prev, downloadPath: e.target.value }))} placeholder="C:\Users\YourUsername\Music"/> + setTempSettings((prev) => ({ ...prev, downloadPath: e.target.value }))} placeholder="C:\Users\YourUsername\Music" />
@@ -183,7 +183,7 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting setTempSettings((prev) => ({ ...prev, theme: value }))}> - + {themes.map((theme) => ( + backgroundColor: isDark ? theme.cssVars.dark.primary : theme.cssVars.light.primary + }} /> {theme.label} ))} @@ -218,7 +218,7 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting setTempSettings((prev) => ({ ...prev, downloader: value }))}> - + Auto @@ -279,14 +279,11 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting )} - {tempSettings.downloader === "amazon" && ()} + {tempSettings.downloader === "amazon" && ( +
+ 16-bit/44.1kHz or 24-bit/48kHz+ +
+ )}
@@ -294,15 +291,15 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
- setTempSettings(prev => ({ ...prev, embedLyrics: checked }))}/> + setTempSettings(prev => ({ ...prev, embedLyrics: checked }))} />
- setTempSettings(prev => ({ ...prev, embedMaxQualityCover: checked }))}/> + setTempSettings(prev => ({ ...prev, embedMaxQualityCover: checked }))} />
-
+
@@ -310,7 +307,7 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting - +

Variables: {TEMPLATE_VARIABLES.map(v => v.key).join(", ")}

@@ -319,13 +316,13 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
- {tempSettings.folderPreset === "custom" && ( setTempSettings(prev => ({ ...prev, folderTemplate: e.target.value }))} placeholder="{artist}/{album}" className="h-9 text-sm flex-1"/>)} + {tempSettings.folderPreset === "custom" && ( setTempSettings(prev => ({ ...prev, folderTemplate: e.target.value }))} placeholder="{artist}/{album}" className="h-9 text-sm flex-1" />)}
{tempSettings.folderTemplate && (

Preview: {tempSettings.folderTemplate.replace(/\{artist\}/g, "Kendrick Lamar, SZA").replace(/\{album\}/g, "Black Panther").replace(/\{album_artist\}/g, "Kendrick Lamar").replace(/\{year\}/g, "2018")}/

)}
-
+
@@ -348,7 +345,7 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting - +

Variables: {TEMPLATE_VARIABLES.map(v => v.key).join(", ")}

@@ -357,13 +354,13 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
- {tempSettings.filenamePreset === "custom" && ( setTempSettings(prev => ({ ...prev, filenameTemplate: e.target.value }))} placeholder="{track}. {title}" className="h-9 text-sm flex-1"/>)} + {tempSettings.filenamePreset === "custom" && ( setTempSettings(prev => ({ ...prev, filenameTemplate: e.target.value }))} placeholder="{track}. {title}" className="h-9 text-sm flex-1" />)}
{tempSettings.filenameTemplate && (

Preview: {tempSettings.filenameTemplate.replace(/\{artist\}/g, "Kendrick Lamar, SZA").replace(/\{album_artist\}/g, "Kendrick Lamar").replace(/\{title\}/g, "All The Stars").replace(/\{track\}/g, "01").replace(/\{disc\}/g, "1").replace(/\{year\}/g, "2018")}.flac @@ -383,11 +380,11 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting

@@ -419,24 +416,24 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting
{isInstallingFFmpeg && (
-
-
- Downloading & Extracting... - {downloadProgress.is_downloading && downloadProgress.mb_downloaded > 0 && ( - {downloadProgress.mb_downloaded.toFixed(2)} MB - {downloadProgress.speed_mbps > 0 && ` @ ${downloadProgress.speed_mbps.toFixed(2)} MB/s`} - )} -
- {installProgress}% +
+
+ Downloading & Extracting... + {downloadProgress.is_downloading && downloadProgress.mb_downloaded > 0 && ( + {downloadProgress.mb_downloaded.toFixed(2)} MB + {downloadProgress.speed_mbps > 0 && ` @ ${downloadProgress.speed_mbps.toFixed(2)} MB/s`} + )}
- -
)} + {installProgress}% +
+ +
)} {!isInstallingFFmpeg && ( - - - )} + + + )}
); diff --git a/frontend/src/hooks/useCover.ts b/frontend/src/hooks/useCover.ts index af929e8..7ab8214 100644 --- a/frontend/src/hooks/useCover.ts +++ b/frontend/src/hooks/useCover.ts @@ -35,7 +35,9 @@ export function useCover() { track: position, playlist: playlistName?.replace(/\//g, placeholder), }; - if (playlistName && !isAlbum) { + const folderTemplate = settings.folderTemplate || ""; + const useAlbumSubfolder = folderTemplate.includes("{album}") || folderTemplate.includes("{album_artist}") || folderTemplate.includes("{playlist}"); + if (playlistName && (!isAlbum || !useAlbumSubfolder)) { outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os)); } if (settings.folderTemplate) { @@ -129,7 +131,9 @@ export function useCover() { track: trackPosition, playlist: playlistName?.replace(/\//g, placeholder), }; - if (playlistName && !isAlbum) { + const folderTemplate = settings.folderTemplate || ""; + const useAlbumSubfolder = folderTemplate.includes("{album}") || folderTemplate.includes("{album_artist}") || folderTemplate.includes("{playlist}"); + if (playlistName && (!isAlbum || !useAlbumSubfolder)) { outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os)); } if (settings.folderTemplate) { diff --git a/frontend/src/hooks/useDownload.ts b/frontend/src/hooks/useDownload.ts index aa6f6f1..a50239b 100644 --- a/frontend/src/hooks/useDownload.ts +++ b/frontend/src/hooks/useDownload.ts @@ -44,7 +44,7 @@ export function useDownload() { const shouldStopDownloadRef = useRef(false); const downloadWithAutoFallback = async (isrc: string, settings: any, trackName?: string, artistName?: string, albumName?: string, playlistName?: string, position?: number, spotifyId?: string, durationMs?: number, releaseYear?: string, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => { const service = settings.downloader; - const query = trackName && artistName ? `${trackName} ${artistName}` : undefined; + const query = trackName && artistName ? `${trackName} ${artistName} ` : undefined; const os = settings.operatingSystem; let outputDir = settings.downloadPath; let useAlbumTrackNumber = false; @@ -82,7 +82,9 @@ export function useDownload() { year: yearValue, playlist: playlistName?.replace(/\//g, placeholder), }; - if (playlistName) { + const folderTemplate = settings.folderTemplate || ""; + const useAlbumSubfolder = folderTemplate.includes("{album}") || folderTemplate.includes("{album_artist}") || folderTemplate.includes("{playlist}"); + if (playlistName && !useAlbumSubfolder) { outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os)); } if (settings.folderTemplate) { @@ -346,7 +348,8 @@ export function useDownload() { year: yearValue, playlist: folderName?.replace(/\//g, placeholder), }; - if (folderName && !isAlbum) { + const useAlbumTag = settings.folderTemplate?.includes("{album}"); + if (folderName && (!isAlbum || !useAlbumTag)) { outputDir = joinPath(os, outputDir, sanitizePath(folderName.replace(/\//g, " "), os)); } if (settings.folderTemplate) { @@ -575,7 +578,8 @@ export function useDownload() { setDownloadProgress(0); let outputDir = settings.downloadPath; const os = settings.operatingSystem; - if (folderName && !isAlbum) { + const useAlbumTag = settings.folderTemplate?.includes("{album}"); + if (folderName && (!isAlbum || !useAlbumTag)) { outputDir = joinPath(os, outputDir, sanitizePath(folderName.replace(/\//g, " "), os)); } const selectedTrackObjects = selectedTracks @@ -723,7 +727,8 @@ export function useDownload() { setDownloadProgress(0); let outputDir = settings.downloadPath; const os = settings.operatingSystem; - if (folderName && !isAlbum) { + const useAlbumTag = settings.folderTemplate?.includes("{album}"); + if (folderName && (!isAlbum || !useAlbumTag)) { outputDir = joinPath(os, outputDir, sanitizePath(folderName.replace(/\//g, " "), os)); } logger.info(`checking existing files in parallel...`); diff --git a/frontend/src/hooks/useLyrics.ts b/frontend/src/hooks/useLyrics.ts index a95400e..59efd3c 100644 --- a/frontend/src/hooks/useLyrics.ts +++ b/frontend/src/hooks/useLyrics.ts @@ -32,7 +32,9 @@ export function useLyrics() { track: position, playlist: playlistName?.replace(/\//g, placeholder), }; - if (playlistName && !isAlbum) { + const folderTemplate = settings.folderTemplate || ""; + const useAlbumSubfolder = folderTemplate.includes("{album}") || folderTemplate.includes("{album_artist}") || folderTemplate.includes("{playlist}"); + if (playlistName && (!isAlbum || !useAlbumSubfolder)) { outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os)); } if (settings.folderTemplate) { @@ -125,7 +127,9 @@ export function useLyrics() { track: trackPosition, playlist: playlistName?.replace(/\//g, placeholder), }; - if (playlistName && !isAlbum) { + const folderTemplate = settings.folderTemplate || ""; + const useAlbumSubfolder = folderTemplate.includes("{album}") || folderTemplate.includes("{album_artist}") || folderTemplate.includes("{playlist}"); + if (playlistName && (!isAlbum || !useAlbumSubfolder)) { outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os)); } if (settings.folderTemplate) { diff --git a/frontend/src/lib/settings.ts b/frontend/src/lib/settings.ts index 86974d4..a3057fe 100644 --- a/frontend/src/lib/settings.ts +++ b/frontend/src/lib/settings.ts @@ -22,7 +22,7 @@ export interface Settings { operatingSystem: "Windows" | "linux/MacOS"; tidalQuality: "LOSSLESS" | "HI_RES_LOSSLESS"; qobuzQuality: "6" | "7"; - amazonQuality: "HI_RES"; + amazonQuality: "original"; } export const FOLDER_PRESETS: Record f.value === fontFamily); if (font) { @@ -194,7 +194,7 @@ function getSettingsFromLocalStorage(): Settings { parsed.qobuzQuality = "6"; } if (!('amazonQuality' in parsed)) { - parsed.amazonQuality = "HI_RES"; + parsed.amazonQuality = "original"; } return { ...DEFAULT_SETTINGS, ...parsed }; } @@ -264,7 +264,7 @@ export async function loadSettings(): Promise { parsed.qobuzQuality = "6"; } if (!('amazonQuality' in parsed)) { - parsed.amazonQuality = "HI_RES"; + parsed.amazonQuality = "original"; } cachedSettings = { ...DEFAULT_SETTINGS, ...parsed }; return cachedSettings!; diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 7528ce2..17f7244 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -45,6 +45,7 @@ export interface AlbumResponse { track_list: TrackMetadata[]; } export interface PlaylistInfo { + name: string; tracks: { total: number; };