This commit is contained in:
afkarxyz
2025-12-08 19:33:43 +07:00
parent 2fb544d1f8
commit 8f10094e40
24 changed files with 1506 additions and 781 deletions
+3
View File
@@ -56,6 +56,9 @@ temp/
*.bak *.bak
*.old *.old
# Test files
test
# Build notes (optional - uncomment if you don't want to commit) # Build notes (optional - uncomment if you don't want to commit)
# BUILD_NOTES.md # BUILD_NOTES.md
build.txt build.txt
+3
View File
@@ -131,6 +131,9 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
if req.OutputDir == "" { if req.OutputDir == "" {
req.OutputDir = "." req.OutputDir = "."
} else {
// Sanitize output directory path to remove invalid characters
req.OutputDir = backend.SanitizeFolderPath(req.OutputDir)
} }
if req.AudioFormat == "" { if req.AudioFormat == "" {
+31 -11
View File
@@ -10,6 +10,7 @@ import (
"net/url" "net/url"
"os" "os"
"path/filepath" "path/filepath"
"regexp"
"strings" "strings"
"time" "time"
) )
@@ -390,18 +391,37 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, filenameFormat st
// Build filename based on format settings // Build filename based on format settings
var newFilename string var newFilename string
switch filenameFormat {
case "artist-title":
newFilename = fmt.Sprintf("%s - %s", safeArtist, safeTitle)
case "title":
newFilename = safeTitle
default: // "title-artist"
newFilename = fmt.Sprintf("%s - %s", safeTitle, safeArtist)
}
// Add track number prefix if enabled // Check if format is a template (contains {})
if includeTrackNumber && position > 0 { if strings.Contains(filenameFormat, "{") {
newFilename = fmt.Sprintf("%02d. %s", position, newFilename) newFilename = filenameFormat
newFilename = strings.ReplaceAll(newFilename, "{title}", safeTitle)
newFilename = strings.ReplaceAll(newFilename, "{artist}", safeArtist)
// Handle track number - if position is 0, remove {track} and surrounding separators
if position > 0 {
newFilename = strings.ReplaceAll(newFilename, "{track}", fmt.Sprintf("%02d", position))
} else {
// Remove {track} with common separators
newFilename = regexp.MustCompile(`\{track\}\.\s*`).ReplaceAllString(newFilename, "")
newFilename = regexp.MustCompile(`\{track\}\s*-\s*`).ReplaceAllString(newFilename, "")
newFilename = regexp.MustCompile(`\{track\}\s*`).ReplaceAllString(newFilename, "")
}
} else {
// Legacy format support
switch filenameFormat {
case "artist-title":
newFilename = fmt.Sprintf("%s - %s", safeArtist, safeTitle)
case "title":
newFilename = safeTitle
default: // "title-artist"
newFilename = fmt.Sprintf("%s - %s", safeTitle, safeArtist)
}
// Add track number prefix if enabled (legacy behavior)
if includeTrackNumber && position > 0 {
newFilename = fmt.Sprintf("%02d. %s", position, newFilename)
}
} }
newFilename = newFilename + ".flac" newFilename = newFilename + ".flac"
+32 -12
View File
@@ -6,6 +6,7 @@ import (
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"regexp"
"strings" "strings"
"time" "time"
) )
@@ -55,19 +56,36 @@ func buildCoverFilename(trackName, artistName, filenameFormat string, includeTra
var filename string var filename string
// Build base filename based on format // Check if format is a template (contains {})
switch filenameFormat { if strings.Contains(filenameFormat, "{") {
case "artist-title": filename = filenameFormat
filename = fmt.Sprintf("%s - %s", safeArtist, safeTitle) filename = strings.ReplaceAll(filename, "{title}", safeTitle)
case "title": filename = strings.ReplaceAll(filename, "{artist}", safeArtist)
filename = safeTitle
default: // "title-artist"
filename = fmt.Sprintf("%s - %s", safeTitle, safeArtist)
}
// Add track number prefix if enabled // Handle track number - if position is 0, remove {track} and surrounding separators
if includeTrackNumber && position > 0 { if position > 0 {
filename = fmt.Sprintf("%02d. %s", position, filename) filename = strings.ReplaceAll(filename, "{track}", fmt.Sprintf("%02d", position))
} else {
// Remove {track} with common separators
filename = regexp.MustCompile(`\{track\}\.\s*`).ReplaceAllString(filename, "")
filename = regexp.MustCompile(`\{track\}\s*-\s*`).ReplaceAllString(filename, "")
filename = regexp.MustCompile(`\{track\}\s*`).ReplaceAllString(filename, "")
}
} else {
// Legacy format support
switch filenameFormat {
case "artist-title":
filename = fmt.Sprintf("%s - %s", safeArtist, safeTitle)
case "title":
filename = safeTitle
default: // "title-artist"
filename = fmt.Sprintf("%s - %s", safeTitle, safeArtist)
}
// Add track number prefix if enabled (legacy behavior)
if includeTrackNumber && position > 0 {
filename = fmt.Sprintf("%02d. %s", position, filename)
}
} }
return filename + ".jpg" return filename + ".jpg"
@@ -102,6 +120,8 @@ func (c *CoverClient) DownloadCover(req CoverDownloadRequest) (*CoverDownloadRes
outputDir := req.OutputDir outputDir := req.OutputDir
if outputDir == "" { if outputDir == "" {
outputDir = GetDefaultMusicPath() outputDir = GetDefaultMusicPath()
} else {
outputDir = SanitizeFolderPath(outputDir)
} }
if err := os.MkdirAll(outputDir, 0755); err != nil { if err := os.MkdirAll(outputDir, 0755); err != nil {
+34 -15
View File
@@ -8,6 +8,7 @@ import (
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"regexp"
"strings" "strings"
"time" "time"
) )
@@ -229,24 +230,42 @@ func (d *DeezerDownloader) DownloadCoverArt(coverURL, filepath string) error {
func buildFilename(title, artist string, trackNumber int, format string, includeTrackNumber bool, position int, useAlbumTrackNumber bool) string { func buildFilename(title, artist string, trackNumber int, format string, includeTrackNumber bool, position int, useAlbumTrackNumber bool) string {
var filename string var filename string
// Build base filename based on format // Determine track number to use
switch format { numberToUse := position
case "artist-title": if useAlbumTrackNumber && trackNumber > 0 {
filename = fmt.Sprintf("%s - %s", artist, title) numberToUse = trackNumber
case "title":
filename = title
default: // "title-artist"
filename = fmt.Sprintf("%s - %s", title, artist)
} }
// Add track number prefix if enabled // Check if format is a template (contains {})
if includeTrackNumber && position > 0 { if strings.Contains(format, "{") {
// Use album track number if in album folder structure, otherwise use playlist position filename = format
numberToUse := position filename = strings.ReplaceAll(filename, "{title}", title)
if useAlbumTrackNumber && trackNumber > 0 { filename = strings.ReplaceAll(filename, "{artist}", artist)
numberToUse = trackNumber
// Handle track number - if numberToUse is 0, remove {track} and surrounding separators
if numberToUse > 0 {
filename = strings.ReplaceAll(filename, "{track}", fmt.Sprintf("%02d", numberToUse))
} else {
// Remove {track} with common separators
filename = regexp.MustCompile(`\{track\}\.\s*`).ReplaceAllString(filename, "")
filename = regexp.MustCompile(`\{track\}\s*-\s*`).ReplaceAllString(filename, "")
filename = regexp.MustCompile(`\{track\}\s*`).ReplaceAllString(filename, "")
}
} else {
// Legacy format support
switch format {
case "artist-title":
filename = fmt.Sprintf("%s - %s", artist, title)
case "title":
filename = title
default: // "title-artist"
filename = fmt.Sprintf("%s - %s", title, artist)
}
// Add track number prefix if enabled (legacy behavior)
if includeTrackNumber && position > 0 {
filename = fmt.Sprintf("%02d. %s", numberToUse, filename)
} }
filename = fmt.Sprintf("%02d. %s", numberToUse, filename)
} }
return filename + ".flac" return filename + ".flac"
+71 -14
View File
@@ -2,6 +2,7 @@ package backend
import ( import (
"fmt" "fmt"
"path/filepath"
"regexp" "regexp"
"strings" "strings"
) )
@@ -14,21 +15,36 @@ func BuildExpectedFilename(trackName, artistName, filenameFormat string, include
var filename string var filename string
// Build base filename based on format // Check if format is a template (contains {})
switch filenameFormat { if strings.Contains(filenameFormat, "{") {
case "artist-title": filename = filenameFormat
filename = fmt.Sprintf("%s - %s", safeArtist, safeTitle) filename = strings.ReplaceAll(filename, "{title}", safeTitle)
case "title": filename = strings.ReplaceAll(filename, "{artist}", safeArtist)
filename = safeTitle
default: // "title-artist"
filename = fmt.Sprintf("%s - %s", safeTitle, safeArtist)
}
// Add track number prefix if enabled // Handle track number - if position is 0, remove {track} and surrounding separators
// Note: We can't determine the exact track number without fetching from API if position > 0 {
// So we only add it if position > 0 (bulk download) filename = strings.ReplaceAll(filename, "{track}", fmt.Sprintf("%02d", position))
if includeTrackNumber && position > 0 { } else {
filename = fmt.Sprintf("%02d. %s", position, filename) // Remove {track} with common separators like ". " or " - " or ". "
filename = regexp.MustCompile(`\{track\}\.\s*`).ReplaceAllString(filename, "")
filename = regexp.MustCompile(`\{track\}\s*-\s*`).ReplaceAllString(filename, "")
filename = regexp.MustCompile(`\{track\}\s*`).ReplaceAllString(filename, "")
}
} else {
// Legacy format support
switch filenameFormat {
case "artist-title":
filename = fmt.Sprintf("%s - %s", safeArtist, safeTitle)
case "title":
filename = safeTitle
default: // "title-artist"
filename = fmt.Sprintf("%s - %s", safeTitle, safeArtist)
}
// Add track number prefix if enabled (legacy behavior)
if includeTrackNumber && position > 0 {
filename = fmt.Sprintf("%02d. %s", position, filename)
}
} }
return filename + ".flac" return filename + ".flac"
@@ -44,3 +60,44 @@ func sanitizeFilename(name string) string {
} }
return sanitized return sanitized
} }
// SanitizeFolderPath sanitizes each component of a folder path and normalizes separators
func SanitizeFolderPath(folderPath string) string {
// Normalize all forward slashes to backslashes on Windows
normalizedPath := strings.ReplaceAll(folderPath, "/", string(filepath.Separator))
// Detect separator
sep := string(filepath.Separator)
// Split path into components
parts := strings.Split(normalizedPath, sep)
sanitizedParts := make([]string, 0, len(parts))
for i, part := range parts {
// Keep drive letter intact on Windows (e.g., "C:")
if i == 0 && len(part) == 2 && part[1] == ':' {
sanitizedParts = append(sanitizedParts, part)
continue
}
// Sanitize each folder name (but don't replace / or \ since we already normalized)
sanitized := sanitizeFolderName(part)
if sanitized != "" {
sanitizedParts = append(sanitizedParts, sanitized)
}
}
return strings.Join(sanitizedParts, sep)
}
// sanitizeFolderName removes invalid characters from a single folder name
func sanitizeFolderName(name string) string {
// Remove or replace invalid characters for folder names (excluding path separators)
re := regexp.MustCompile(`[<>:"|?*]`)
sanitized := re.ReplaceAllString(name, "_")
sanitized = strings.TrimSpace(sanitized)
if sanitized == "" {
return "Unknown"
}
return sanitized
}
+32 -12
View File
@@ -8,6 +8,7 @@ import (
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"regexp"
"strings" "strings"
"time" "time"
) )
@@ -132,19 +133,36 @@ func buildLyricsFilename(trackName, artistName, filenameFormat string, includeTr
var filename string var filename string
// Build base filename based on format // Check if format is a template (contains {})
switch filenameFormat { if strings.Contains(filenameFormat, "{") {
case "artist-title": filename = filenameFormat
filename = fmt.Sprintf("%s - %s", safeArtist, safeTitle) filename = strings.ReplaceAll(filename, "{title}", safeTitle)
case "title": filename = strings.ReplaceAll(filename, "{artist}", safeArtist)
filename = safeTitle
default: // "title-artist"
filename = fmt.Sprintf("%s - %s", safeTitle, safeArtist)
}
// Add track number prefix if enabled // Handle track number - if position is 0, remove {track} and surrounding separators
if includeTrackNumber && position > 0 { if position > 0 {
filename = fmt.Sprintf("%02d. %s", position, filename) filename = strings.ReplaceAll(filename, "{track}", fmt.Sprintf("%02d", position))
} else {
// Remove {track} with common separators
filename = regexp.MustCompile(`\{track\}\.\s*`).ReplaceAllString(filename, "")
filename = regexp.MustCompile(`\{track\}\s*-\s*`).ReplaceAllString(filename, "")
filename = regexp.MustCompile(`\{track\}\s*`).ReplaceAllString(filename, "")
}
} else {
// Legacy format support
switch filenameFormat {
case "artist-title":
filename = fmt.Sprintf("%s - %s", safeArtist, safeTitle)
case "title":
filename = safeTitle
default: // "title-artist"
filename = fmt.Sprintf("%s - %s", safeTitle, safeArtist)
}
// Add track number prefix if enabled (legacy behavior)
if includeTrackNumber && position > 0 {
filename = fmt.Sprintf("%02d. %s", position, filename)
}
} }
return filename + ".lrc" return filename + ".lrc"
@@ -163,6 +181,8 @@ func (c *LyricsClient) DownloadLyrics(req LyricsDownloadRequest) (*LyricsDownloa
outputDir := req.OutputDir outputDir := req.OutputDir
if outputDir == "" { if outputDir == "" {
outputDir = GetDefaultMusicPath() outputDir = GetDefaultMusicPath()
} else {
outputDir = SanitizeFolderPath(outputDir)
} }
if err := os.MkdirAll(outputDir, 0755); err != nil { if err := os.MkdirAll(outputDir, 0755); err != nil {
+7
View File
@@ -264,6 +264,13 @@ func UpdateItemProgress(id string, progress, speed float64) {
} }
} }
// GetCurrentItemID returns the ID of the currently downloading item
func GetCurrentItemID() string {
currentItemLock.RLock()
defer currentItemLock.RUnlock()
return currentItemID
}
// CompleteDownloadItem marks an item as completed // CompleteDownloadItem marks an item as completed
func CompleteDownloadItem(id, filePath string, finalSize float64) { func CompleteDownloadItem(id, filePath string, finalSize float64) {
downloadQueueLock.Lock() downloadQueueLock.Lock()
+35 -15
View File
@@ -8,6 +8,8 @@ import (
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"regexp"
"strings"
"time" "time"
) )
@@ -225,24 +227,42 @@ func (q *QobuzDownloader) DownloadCoverArt(coverURL, filepath string) error {
func buildQobuzFilename(title, artist string, trackNumber int, format string, includeTrackNumber bool, position int, useAlbumTrackNumber bool) string { func buildQobuzFilename(title, artist string, trackNumber int, format string, includeTrackNumber bool, position int, useAlbumTrackNumber bool) string {
var filename string var filename string
// Build base filename based on format // Determine track number to use
switch format { numberToUse := position
case "artist-title": if useAlbumTrackNumber && trackNumber > 0 {
filename = fmt.Sprintf("%s - %s", artist, title) numberToUse = trackNumber
case "title":
filename = title
default: // "title-artist"
filename = fmt.Sprintf("%s - %s", title, artist)
} }
// Add track number prefix if enabled // Check if format is a template (contains {})
if includeTrackNumber && position > 0 { if strings.Contains(format, "{") {
// Use album track number if in album folder structure, otherwise use playlist position filename = format
numberToUse := position filename = strings.ReplaceAll(filename, "{title}", title)
if useAlbumTrackNumber && trackNumber > 0 { filename = strings.ReplaceAll(filename, "{artist}", artist)
numberToUse = trackNumber
// Handle track number - if numberToUse is 0, remove {track} and surrounding separators
if numberToUse > 0 {
filename = strings.ReplaceAll(filename, "{track}", fmt.Sprintf("%02d", numberToUse))
} else {
// Remove {track} with common separators
filename = regexp.MustCompile(`\{track\}\.\s*`).ReplaceAllString(filename, "")
filename = regexp.MustCompile(`\{track\}\s*-\s*`).ReplaceAllString(filename, "")
filename = regexp.MustCompile(`\{track\}\s*`).ReplaceAllString(filename, "")
}
} else {
// Legacy format support
switch format {
case "artist-title":
filename = fmt.Sprintf("%s - %s", artist, title)
case "title":
filename = title
default: // "title-artist"
filename = fmt.Sprintf("%s - %s", title, artist)
}
// Add track number prefix if enabled (legacy behavior)
if includeTrackNumber && position > 0 {
filename = fmt.Sprintf("%02d. %s", numberToUse, filename)
} }
filename = fmt.Sprintf("%02d. %s", numberToUse, filename)
} }
return filename + ".flac" return filename + ".flac"
+384 -39
View File
@@ -3,12 +3,15 @@ package backend
import ( import (
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"encoding/xml"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"regexp"
"strings" "strings"
"time" "time"
) )
@@ -59,11 +62,35 @@ type TidalAPIResponse struct {
OriginalTrackURL string `json:"OriginalTrackUrl"` OriginalTrackURL string `json:"OriginalTrackUrl"`
} }
// TidalAPIResponseV2 is the new API response format (version 2.0)
type TidalAPIResponseV2 struct {
Version string `json:"version"`
Data struct {
TrackID int64 `json:"trackId"`
AssetPresentation string `json:"assetPresentation"`
AudioMode string `json:"audioMode"`
AudioQuality string `json:"audioQuality"`
ManifestMimeType string `json:"manifestMimeType"`
ManifestHash string `json:"manifestHash"`
Manifest string `json:"manifest"`
BitDepth int `json:"bitDepth"`
SampleRate int `json:"sampleRate"`
} `json:"data"`
}
type TidalAPIInfo struct { type TidalAPIInfo struct {
URL string `json:"url"` URL string `json:"url"`
Status string `json:"status"` Status string `json:"status"`
} }
// TidalBTSManifest is the BTS (application/vnd.tidal.bts) manifest format
type TidalBTSManifest struct {
MimeType string `json:"mimeType"`
Codecs string `json:"codecs"`
EncryptionType string `json:"encryptionType"`
URLs []string `json:"urls"`
}
func NewTidalDownloader(apiURL string) *TidalDownloader { func NewTidalDownloader(apiURL string) *TidalDownloader {
clientID, _ := base64.StdEncoding.DecodeString("NkJEU1JkcEs5aHFFQlRnVQ==") clientID, _ := base64.StdEncoding.DecodeString("NkJEU1JkcEs5aHFFQlRnVQ==")
clientSecret, _ := base64.StdEncoding.DecodeString("eGV1UG1ZN25icFo5SUliTEFjUTkzc2hrYTFWTmhlVUFxTjZJY3N6alRHOD0=") clientSecret, _ := base64.StdEncoding.DecodeString("eGV1UG1ZN25icFo5SUliTEFjUTkzc2hrYTFWTmhlVUFxTjZJY3N6alRHOD0=")
@@ -72,7 +99,7 @@ func NewTidalDownloader(apiURL string) *TidalDownloader {
if apiURL == "" { if apiURL == "" {
downloader := &TidalDownloader{ downloader := &TidalDownloader{
client: &http.Client{ client: &http.Client{
Timeout: 5 * time.Second, // Fast timeout for quick API fallback Timeout: 5 * time.Second,
}, },
timeout: 5 * time.Second, timeout: 5 * time.Second,
maxRetries: 3, maxRetries: 3,
@@ -84,13 +111,13 @@ func NewTidalDownloader(apiURL string) *TidalDownloader {
// Try to get available APIs // Try to get available APIs
apis, err := downloader.GetAvailableAPIs() apis, err := downloader.GetAvailableAPIs()
if err == nil && len(apis) > 0 { if err == nil && len(apis) > 0 {
apiURL = apis[0] // Use first available API apiURL = apis[0]
} }
} }
return &TidalDownloader{ return &TidalDownloader{
client: &http.Client{ client: &http.Client{
Timeout: 5 * time.Second, // Fast timeout for quick API fallback Timeout: 5 * time.Second,
}, },
timeout: 5 * time.Second, timeout: 5 * time.Second,
maxRetries: 3, maxRetries: 3,
@@ -527,8 +554,23 @@ func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (string,
return "", fmt.Errorf("API returned status code: %d", resp.StatusCode) return "", fmt.Errorf("API returned status code: %d", resp.StatusCode)
} }
// Read body to try both formats
body, err := io.ReadAll(resp.Body)
if err != nil {
fmt.Printf("✗ Failed to read response body: %v\n", err)
return "", fmt.Errorf("failed to read response: %w", err)
}
// Try v2 format first (object with manifest)
var v2Response TidalAPIResponseV2
if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" {
fmt.Println("✓ Tidal manifest found (v2 API)")
return "MANIFEST:" + v2Response.Data.Manifest, nil
}
// Fallback to v1 format (array with OriginalTrackUrl)
var apiResponses []TidalAPIResponse var apiResponses []TidalAPIResponse
if err := json.NewDecoder(resp.Body).Decode(&apiResponses); err != nil { if err := json.Unmarshal(body, &apiResponses); err != nil {
fmt.Printf("✗ Failed to decode Tidal API response: %v\n", err) fmt.Printf("✗ Failed to decode Tidal API response: %v\n", err)
return "", fmt.Errorf("failed to decode response: %w", err) return "", fmt.Errorf("failed to decode response: %w", err)
} }
@@ -569,6 +611,11 @@ func (t *TidalDownloader) DownloadAlbumArt(albumID string) ([]byte, error) {
} }
func (t *TidalDownloader) DownloadFile(url, filepath string) error { func (t *TidalDownloader) DownloadFile(url, filepath string) error {
// Check if this is a manifest-based download
if strings.HasPrefix(url, "MANIFEST:") {
return t.DownloadFromManifest(strings.TrimPrefix(url, "MANIFEST:"), filepath)
}
resp, err := t.client.Get(url) resp, err := t.client.Get(url)
if err != nil { if err != nil {
return fmt.Errorf("failed to download file: %w", err) return fmt.Errorf("failed to download file: %w", err)
@@ -599,6 +646,155 @@ func (t *TidalDownloader) DownloadFile(url, filepath string) error {
return nil return nil
} }
// DownloadFromManifest downloads audio from manifest (supports BTS and DASH formats)
func (t *TidalDownloader) DownloadFromManifest(manifestB64, outputPath string) error {
directURL, initURL, mediaURLs, err := parseManifest(manifestB64)
if err != nil {
return fmt.Errorf("failed to parse manifest: %w", err)
}
// Create HTTP client with longer timeout
client := &http.Client{
Timeout: 120 * time.Second,
}
// If we have a direct URL (BTS format), download directly
if directURL != "" {
fmt.Println("Downloading file...")
resp, err := client.Get(directURL)
if err != nil {
return fmt.Errorf("failed to download file: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("download failed with status %d", resp.StatusCode)
}
out, err := os.Create(outputPath)
if err != nil {
return fmt.Errorf("failed to create file: %w", err)
}
defer out.Close()
// Use progress writer to track download
pw := NewProgressWriter(out)
_, err = io.Copy(pw, resp.Body)
if err != nil {
return fmt.Errorf("failed to write file: %w", err)
}
fmt.Printf("\rDownloaded: %.2f MB (Complete)\n", float64(pw.GetTotal())/(1024*1024))
fmt.Println("Download complete")
return nil
}
// DASH format - download segments to temporary M4A file, then remux to FLAC
fmt.Printf("Downloading %d segments...\n", len(mediaURLs)+1)
// Create temporary file for M4A segments
tempPath := outputPath + ".m4a.tmp"
out, err := os.Create(tempPath)
if err != nil {
return fmt.Errorf("failed to create temp file: %w", err)
}
// Download initialization segment
fmt.Print("Downloading init segment... ")
resp, err := client.Get(initURL)
if err != nil {
out.Close()
os.Remove(tempPath)
return fmt.Errorf("failed to download init segment: %w", err)
}
if resp.StatusCode != 200 {
resp.Body.Close()
out.Close()
os.Remove(tempPath)
return fmt.Errorf("init segment download failed with status %d", resp.StatusCode)
}
_, err = io.Copy(out, resp.Body)
resp.Body.Close()
if err != nil {
out.Close()
os.Remove(tempPath)
return fmt.Errorf("failed to write init segment: %w", err)
}
fmt.Println("OK")
// Download media segments with progress tracking
totalSegments := len(mediaURLs)
var totalBytes int64
lastTime := time.Now()
var lastBytes int64
for i, mediaURL := range mediaURLs {
resp, err := client.Get(mediaURL)
if err != nil {
out.Close()
os.Remove(tempPath)
return fmt.Errorf("failed to download segment %d: %w", i+1, err)
}
if resp.StatusCode != 200 {
resp.Body.Close()
out.Close()
os.Remove(tempPath)
return fmt.Errorf("segment %d download failed with status %d", i+1, resp.StatusCode)
}
n, err := io.Copy(out, resp.Body)
totalBytes += n
resp.Body.Close()
if err != nil {
out.Close()
os.Remove(tempPath)
return fmt.Errorf("failed to write segment %d: %w", i+1, err)
}
// Calculate speed and update progress for frontend
mbDownloaded := float64(totalBytes) / (1024 * 1024)
now := time.Now()
timeDiff := now.Sub(lastTime).Seconds()
var speedMBps float64
if timeDiff > 0.1 { // Update speed every 100ms
bytesDiff := float64(totalBytes - lastBytes)
speedMBps = (bytesDiff / (1024 * 1024)) / timeDiff
SetDownloadSpeed(speedMBps)
lastTime = now
lastBytes = totalBytes
}
SetDownloadProgress(mbDownloaded)
// Show progress with size in terminal
fmt.Printf("\rDownloading: %.2f MB (%d/%d segments)", mbDownloaded, i+1, totalSegments)
}
// Close temp file before remuxing
out.Close()
// Get temp file size
tempInfo, _ := os.Stat(tempPath)
fmt.Printf("\rDownloaded: %.2f MB (Complete) \n", float64(tempInfo.Size())/(1024*1024))
// Remux M4A to FLAC using ffmpeg
// DASH segments are in fMP4 container with FLAC codec, need to extract to native FLAC
fmt.Println("Converting to FLAC...")
cmd := exec.Command("ffmpeg", "-y", "-i", tempPath, "-vn", "-c:a", "flac", outputPath)
var stderr strings.Builder
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
// If ffmpeg fails, try to keep the M4A file for debugging
m4aPath := strings.TrimSuffix(outputPath, ".flac") + ".m4a"
os.Rename(tempPath, m4aPath)
return fmt.Errorf("ffmpeg conversion failed (M4A saved as %s): %w - %s", m4aPath, err, stderr.String())
}
// Remove temp file
os.Remove(tempPath)
fmt.Println("Download complete")
return nil
}
func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName string, useAlbumTrackNumber bool) (string, error) { func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName string, useAlbumTrackNumber bool) (string, error) {
if outputDir != "." { if outputDir != "." {
if err := os.MkdirAll(outputDir, 0755); err != nil { if err := os.MkdirAll(outputDir, 0755); err != nil {
@@ -1050,21 +1246,132 @@ func (t *TidalDownloader) DownloadBySearchWithISRC(trackName, artistName, albumN
return outputFilename, nil return outputFilename, nil
} }
// apiResult holds the result from a parallel API request // DASH MPD XML structures for parsing manifest
type apiResult struct { type MPD struct {
apiURL string XMLName xml.Name `xml:"MPD"`
downloadURL string Period struct {
err error AdaptationSet struct {
Representation struct {
SegmentTemplate struct {
Initialization string `xml:"initialization,attr"`
Media string `xml:"media,attr"`
Timeline struct {
Segments []struct {
Duration int `xml:"d,attr"`
Repeat int `xml:"r,attr"`
} `xml:"S"`
} `xml:"SegmentTimeline"`
} `xml:"SegmentTemplate"`
} `xml:"Representation"`
} `xml:"AdaptationSet"`
} `xml:"Period"`
}
// parseManifest extracts download URL from base64 encoded manifest
// Supports both BTS (JSON) and DASH (XML) formats
// Returns: directURL (for BTS), or initURL + mediaURLs (for DASH)
func parseManifest(manifestB64 string) (directURL string, initURL string, mediaURLs []string, err error) {
// Decode base64 manifest
manifestBytes, err := base64.StdEncoding.DecodeString(manifestB64)
if err != nil {
return "", "", nil, fmt.Errorf("failed to decode manifest: %w", err)
}
manifestStr := string(manifestBytes)
// Check if it's BTS format (JSON) or DASH format (XML)
if strings.HasPrefix(manifestStr, "{") {
// BTS format - JSON with direct URLs
var btsManifest TidalBTSManifest
if err := json.Unmarshal(manifestBytes, &btsManifest); err != nil {
return "", "", nil, fmt.Errorf("failed to parse BTS manifest: %w", err)
}
if len(btsManifest.URLs) == 0 {
return "", "", nil, fmt.Errorf("no URLs in BTS manifest")
}
fmt.Printf("Manifest: BTS format (%s, %s)\n", btsManifest.MimeType, btsManifest.Codecs)
return btsManifest.URLs[0], "", nil, nil
}
// DASH format - XML with segments
fmt.Println("Manifest: DASH format")
// Parse XML
var mpd MPD
if err := xml.Unmarshal(manifestBytes, &mpd); err != nil {
return "", "", nil, fmt.Errorf("failed to parse manifest XML: %w", err)
}
segTemplate := mpd.Period.AdaptationSet.Representation.SegmentTemplate
initURL = segTemplate.Initialization
mediaTemplate := segTemplate.Media
if initURL == "" || mediaTemplate == "" {
// Fallback: try regex extraction
initRe := regexp.MustCompile(`initialization="([^"]+)"`)
mediaRe := regexp.MustCompile(`media="([^"]+)"`)
if match := initRe.FindStringSubmatch(manifestStr); len(match) > 1 {
initURL = match[1]
}
if match := mediaRe.FindStringSubmatch(manifestStr); len(match) > 1 {
mediaTemplate = match[1]
}
}
if initURL == "" {
return "", "", nil, fmt.Errorf("no initialization URL found in manifest")
}
// Unescape HTML entities in URLs
initURL = strings.ReplaceAll(initURL, "&amp;", "&")
mediaTemplate = strings.ReplaceAll(mediaTemplate, "&amp;", "&")
// Calculate segment count from timeline
segmentCount := 0
for _, seg := range segTemplate.Timeline.Segments {
segmentCount += seg.Repeat + 1
}
// If no segments found via XML, try regex
if segmentCount == 0 {
segRe := regexp.MustCompile(`<S d="\d+"(?: r="(\d+)")?`)
matches := segRe.FindAllStringSubmatch(manifestStr, -1)
for _, match := range matches {
repeat := 0
if len(match) > 1 && match[1] != "" {
fmt.Sscanf(match[1], "%d", &repeat)
}
segmentCount += repeat + 1
}
}
// Generate media URLs for each segment
for i := 1; i <= segmentCount; i++ {
mediaURL := strings.ReplaceAll(mediaTemplate, "$Number$", fmt.Sprintf("%d", i))
mediaURLs = append(mediaURLs, mediaURL)
}
return "", initURL, mediaURLs, nil
}
// manifestResult holds the result from a parallel API request for v2 API
type manifestResult struct {
apiURL string
manifest string
err error
} }
// getDownloadURLParallel requests download URL from all APIs in parallel // getDownloadURLParallel requests download URL from all APIs in parallel
// Returns the first successful result // Returns the first successful result (supports both v1 and v2 API formats)
func getDownloadURLParallel(apis []string, trackID int64, quality string) (string, string, error) { func getDownloadURLParallel(apis []string, trackID int64, quality string) (string, string, error) {
if len(apis) == 0 { if len(apis) == 0 {
return "", "", fmt.Errorf("no APIs available") return "", "", fmt.Errorf("no APIs available")
} }
resultChan := make(chan apiResult, len(apis)) resultChan := make(chan manifestResult, len(apis))
// Start all requests in parallel with longer timeout client // Start all requests in parallel with longer timeout client
fmt.Printf("Requesting download URL from %d APIs in parallel...\n", len(apis)) fmt.Printf("Requesting download URL from %d APIs in parallel...\n", len(apis))
@@ -1078,30 +1385,43 @@ func getDownloadURLParallel(apis []string, trackID int64, quality string) (strin
url := fmt.Sprintf("%s/track/?id=%d&quality=%s", api, trackID, quality) url := fmt.Sprintf("%s/track/?id=%d&quality=%s", api, trackID, quality)
resp, err := client.Get(url) resp, err := client.Get(url)
if err != nil { if err != nil {
resultChan <- apiResult{apiURL: api, err: err} resultChan <- manifestResult{apiURL: api, err: err}
return return
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != 200 { if resp.StatusCode != 200 {
resultChan <- apiResult{apiURL: api, err: fmt.Errorf("HTTP %d", resp.StatusCode)} resultChan <- manifestResult{apiURL: api, err: fmt.Errorf("HTTP %d", resp.StatusCode)}
return return
} }
var apiResponses []TidalAPIResponse // Read body to try both formats
if err := json.NewDecoder(resp.Body).Decode(&apiResponses); err != nil { body, err := io.ReadAll(resp.Body)
resultChan <- apiResult{apiURL: api, err: err} if err != nil {
resultChan <- manifestResult{apiURL: api, err: err}
return return
} }
for _, item := range apiResponses { // Try v2 format first (object with manifest)
if item.OriginalTrackURL != "" { var v2Response TidalAPIResponseV2
resultChan <- apiResult{apiURL: api, downloadURL: item.OriginalTrackURL, err: nil} if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" {
return resultChan <- manifestResult{apiURL: api, manifest: v2Response.Data.Manifest, err: nil}
return
}
// Fallback to v1 format (array with OriginalTrackUrl)
var v1Responses []TidalAPIResponse
if err := json.Unmarshal(body, &v1Responses); err == nil {
for _, item := range v1Responses {
if item.OriginalTrackURL != "" {
// For v1, we store the URL directly with a prefix to distinguish
resultChan <- manifestResult{apiURL: api, manifest: "DIRECT:" + item.OriginalTrackURL, err: nil}
return
}
} }
} }
resultChan <- apiResult{apiURL: api, err: fmt.Errorf("no download URL in response")} resultChan <- manifestResult{apiURL: api, err: fmt.Errorf("no download URL or manifest in response")}
}(apiURL) }(apiURL)
} }
@@ -1111,10 +1431,17 @@ func getDownloadURLParallel(apis []string, trackID int64, quality string) (strin
for i := 0; i < len(apis); i++ { for i := 0; i < len(apis); i++ {
result := <-resultChan result := <-resultChan
if result.err == nil && result.downloadURL != "" { if result.err == nil && result.manifest != "" {
// First success - use this one // First success - use this one
fmt.Printf("✓ Got download URL from: %s\n", result.apiURL) fmt.Printf("✓ Got response from: %s\n", result.apiURL)
return result.apiURL, result.downloadURL, nil
// Check if it's a direct URL (v1) or manifest (v2)
if strings.HasPrefix(result.manifest, "DIRECT:") {
return result.apiURL, strings.TrimPrefix(result.manifest, "DIRECT:"), nil
}
// It's a v2 manifest - return it with MANIFEST: prefix
return result.apiURL, "MANIFEST:" + result.manifest, nil
} else { } else {
errMsg := result.err.Error() errMsg := result.err.Error()
if len(errMsg) > 50 { if len(errMsg) > 50 {
@@ -1311,24 +1638,42 @@ func (t *TidalDownloader) DownloadWithFallbackAndISRC(spotifyTrackID, spotifyISR
func buildTidalFilename(title, artist string, trackNumber int, format string, includeTrackNumber bool, position int, useAlbumTrackNumber bool) string { func buildTidalFilename(title, artist string, trackNumber int, format string, includeTrackNumber bool, position int, useAlbumTrackNumber bool) string {
var filename string var filename string
// Build base filename based on format // Determine track number to use
switch format { numberToUse := position
case "artist-title": if useAlbumTrackNumber && trackNumber > 0 {
filename = fmt.Sprintf("%s - %s", artist, title) numberToUse = trackNumber
case "title":
filename = title
default: // "title-artist"
filename = fmt.Sprintf("%s - %s", title, artist)
} }
// Add track number prefix if enabled // Check if format is a template (contains {})
if includeTrackNumber && position > 0 { if strings.Contains(format, "{") {
// Use album track number if in album folder structure, otherwise use playlist position filename = format
numberToUse := position filename = strings.ReplaceAll(filename, "{title}", title)
if useAlbumTrackNumber && trackNumber > 0 { filename = strings.ReplaceAll(filename, "{artist}", artist)
numberToUse = trackNumber
// Handle track number - if numberToUse is 0, remove {track} and surrounding separators
if numberToUse > 0 {
filename = strings.ReplaceAll(filename, "{track}", fmt.Sprintf("%02d", numberToUse))
} else {
// Remove {track} with common separators
filename = regexp.MustCompile(`\{track\}\.\s*`).ReplaceAllString(filename, "")
filename = regexp.MustCompile(`\{track\}\s*-\s*`).ReplaceAllString(filename, "")
filename = regexp.MustCompile(`\{track\}\s*`).ReplaceAllString(filename, "")
}
} else {
// Legacy format support
switch format {
case "artist-title":
filename = fmt.Sprintf("%s - %s", artist, title)
case "title":
filename = title
default: // "title-artist"
filename = fmt.Sprintf("%s - %s", title, artist)
}
// Add track number prefix if enabled (legacy behavior)
if includeTrackNumber && position > 0 {
filename = fmt.Sprintf("%02d. %s", numberToUse, filename)
} }
filename = fmt.Sprintf("%02d. %s", numberToUse, filename)
} }
return filename + ".flac" return filename + ".flac"
+7 -7
View File
@@ -21,16 +21,16 @@
"@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-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.1.3", "@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/vite": "^4.1.17", "@tailwindcss/vite": "^4.1.17",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^0.555.0", "lucide-react": "^0.556.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "^19.2.0", "react": "^19.2.1",
"react-dom": "^19.2.0", "react-dom": "^19.2.1",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.17" "tailwindcss": "^4.1.17"
@@ -40,7 +40,7 @@
"@types/node": "^24.10.1", "@types/node": "^24.10.1",
"@types/react": "^19.2.7", "@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1", "@vitejs/plugin-react": "^5.1.2",
"eslint": "^9.39.1", "eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24", "eslint-plugin-react-refresh": "^0.4.24",
@@ -48,7 +48,7 @@
"sharp": "^0.34.5", "sharp": "^0.34.5",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"typescript-eslint": "^8.48.0", "typescript-eslint": "^8.48.1",
"vite": "^7.2.6" "vite": "^7.2.7"
} }
} }
+1 -1
View File
@@ -1 +1 @@
b7a549e463d5f6a2fad25f5ce939cdd7 58400d5c8f7b03bac8ab784b5e775687
+406 -406
View File
File diff suppressed because it is too large Load Diff
+14 -14
View File
@@ -55,7 +55,7 @@ function App() {
const [fetchHistory, setFetchHistory] = useState<HistoryItem[]>([]); const [fetchHistory, setFetchHistory] = useState<HistoryItem[]>([]);
const ITEMS_PER_PAGE = 50; const ITEMS_PER_PAGE = 50;
const CURRENT_VERSION = "6.6"; const CURRENT_VERSION = "6.7";
const download = useDownload(); const download = useDownload();
const metadata = useMetadata(); const metadata = useMetadata();
@@ -325,16 +325,16 @@ function App() {
onToggleSelectAll={toggleSelectAll} onToggleSelectAll={toggleSelectAll}
onDownloadTrack={download.handleDownloadTrack} onDownloadTrack={download.handleDownloadTrack}
onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position) => onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position) =>
lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, album_info.name, false, position) lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, album_info.name, position)
} }
onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId) => onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId) =>
cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, album_info.name, false, position, trackId) cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, album_info.name, position, trackId)
} }
onCheckAvailability={availability.checkAvailability} onCheckAvailability={availability.checkAvailability}
onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, album_info.name)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, album_info.name)}
onDownloadAll={() => download.handleDownloadAll(track_list, album_info.name)} onDownloadAll={() => download.handleDownloadAll(track_list, undefined, true)}
onDownloadSelected={() => onDownloadSelected={() =>
download.handleDownloadSelected(selectedTracks, track_list, album_info.name) download.handleDownloadSelected(selectedTracks, track_list, undefined, true)
} }
onStopDownload={download.handleStopDownload} onStopDownload={download.handleStopDownload}
onOpenFolder={handleOpenFolder} onOpenFolder={handleOpenFolder}
@@ -391,10 +391,10 @@ function App() {
onToggleSelectAll={toggleSelectAll} onToggleSelectAll={toggleSelectAll}
onDownloadTrack={download.handleDownloadTrack} onDownloadTrack={download.handleDownloadTrack}
onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position) => onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position) =>
lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, playlist_info.owner.name, false, position) lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, playlist_info.owner.name, position)
} }
onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId) => onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId) =>
cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, playlist_info.owner.name, false, position, trackId) cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, playlist_info.owner.name, position, trackId)
} }
onCheckAvailability={availability.checkAvailability} onCheckAvailability={availability.checkAvailability}
onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, playlist_info.owner.name)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, playlist_info.owner.name)}
@@ -462,17 +462,17 @@ function App() {
onToggleTrack={toggleTrackSelection} onToggleTrack={toggleTrackSelection}
onToggleSelectAll={toggleSelectAll} onToggleSelectAll={toggleSelectAll}
onDownloadTrack={download.handleDownloadTrack} onDownloadTrack={download.handleDownloadTrack}
onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, isArtistDiscography, position) => onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position) =>
lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, artist_info.name, isArtistDiscography, position) lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, artist_info.name, position)
} }
onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, isArtistDiscography, position, trackId) => onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId) =>
cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, artist_info.name, isArtistDiscography, position, trackId) cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, artist_info.name, position, trackId)
} }
onCheckAvailability={availability.checkAvailability} onCheckAvailability={availability.checkAvailability}
onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, artist_info.name, true)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, artist_info.name)}
onDownloadAll={() => download.handleDownloadAll(track_list, artist_info.name, true)} onDownloadAll={() => download.handleDownloadAll(track_list, artist_info.name)}
onDownloadSelected={() => onDownloadSelected={() =>
download.handleDownloadSelected(selectedTracks, track_list, artist_info.name, true) download.handleDownloadSelected(selectedTracks, track_list, artist_info.name)
} }
onStopDownload={download.handleStopDownload} onStopDownload={download.handleStopDownload}
onOpenFolder={handleOpenFolder} onOpenFolder={handleOpenFolder}
+82 -62
View File
@@ -16,12 +16,11 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Checkbox } from "@/components/ui/checkbox";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Settings as SettingsIcon, FolderOpen, Save, RotateCcw, Info, X, Volume2 } from "lucide-react"; import { Settings as SettingsIcon, FolderOpen, Save, RotateCcw, Info, X, Volume2 } from "lucide-react";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { getSettings, getSettingsWithDefaults, saveSettings, resetToDefaultSettings, applyThemeMode, type Settings as SettingsType } from "@/lib/settings"; import { getSettings, getSettingsWithDefaults, saveSettings, resetToDefaultSettings, applyThemeMode, FOLDER_PRESETS, FILENAME_PRESETS, TEMPLATE_VARIABLES, type Settings as SettingsType, type FolderPreset, type FilenamePreset } from "@/lib/settings";
import { themes, applyTheme } from "@/lib/themes"; import { themes, applyTheme } from "@/lib/themes";
import { SelectFolder } from "../../wailsjs/go/main/App"; import { SelectFolder } from "../../wailsjs/go/main/App";
@@ -325,81 +324,102 @@ export function Settings() {
{/* Right Column */} {/* Right Column */}
<div className="space-y-4"> <div className="space-y-4">
{/* Filename Format */} {/* Folder Structure */}
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-sm">Filename Format</Label> <div className="flex items-center gap-2">
<RadioGroup <Label className="text-sm">Folder Structure</Label>
value={tempSettings.filenameFormat} <Tooltip>
onValueChange={(value) => setTempSettings(prev => ({ ...prev, filenameFormat: value as any }))} <TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent side="top">
<p className="text-xs whitespace-nowrap">Variables: {TEMPLATE_VARIABLES.map(v => v.key).join(", ")}</p>
</TooltipContent>
</Tooltip>
</div>
<Select
value={tempSettings.folderPreset}
onValueChange={(value: FolderPreset) => {
const preset = FOLDER_PRESETS[value];
setTempSettings(prev => ({
...prev,
folderPreset: value,
folderTemplate: value === "custom" ? prev.folderTemplate : preset.template
}));
}}
> >
<div className="flex items-center space-x-2"> <SelectTrigger className="h-9">
<RadioGroupItem value="title-artist" id="title-artist" /> <SelectValue />
<Label htmlFor="title-artist" className="cursor-pointer font-normal text-sm">Title - Artist</Label> </SelectTrigger>
</div> <SelectContent>
<div className="flex items-center space-x-2"> {Object.entries(FOLDER_PRESETS).map(([key, { label }]) => (
<RadioGroupItem value="artist-title" id="artist-title" /> <SelectItem key={key} value={key}>{label}</SelectItem>
<Label htmlFor="artist-title" className="cursor-pointer font-normal text-sm">Artist - Title</Label> ))}
</div> </SelectContent>
<div className="flex items-center space-x-2"> </Select>
<RadioGroupItem value="title" id="title" /> {tempSettings.folderPreset === "custom" && (
<Label htmlFor="title" className="cursor-pointer font-normal text-sm">Title</Label> <InputWithContext
</div> value={tempSettings.folderTemplate}
</RadioGroup> onChange={(e) => setTempSettings(prev => ({ ...prev, folderTemplate: e.target.value }))}
placeholder="{artist}/{album}"
className="h-9 text-sm"
/>
)}
{tempSettings.folderTemplate && (
<p className="text-xs text-muted-foreground">
Preview: <span className="font-mono">{tempSettings.folderTemplate.replace(/\{artist\}/g, "Taylor Swift").replace(/\{album\}/g, "1989").replace(/\{year\}/g, "2014")}/</span>
</p>
)}
</div> </div>
<div className="border-t" /> <div className="border-t" />
{/* Folder Settings */} {/* Filename Format */}
<div className="space-y-2"> <div className="space-y-2">
<h3 className="font-medium text-sm">Folder Settings</h3>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Checkbox <Label className="text-sm">Filename Format</Label>
id="track-number"
checked={tempSettings.trackNumber}
onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, trackNumber: checked as boolean }))}
/>
<Label htmlFor="track-number" className="cursor-pointer text-sm">Track Number</Label>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" /> <Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top" className="max-w-xs"> <TooltipContent side="top">
<p className="text-xs text-center">Adds track numbers to filenames. Uses album track numbers when Album Subfolder is enabled, otherwise uses playlist position</p> <p className="text-xs whitespace-nowrap">Variables: {TEMPLATE_VARIABLES.map(v => v.key).join(", ")}</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
<div className="flex items-center gap-2"> <Select
<Checkbox value={tempSettings.filenamePreset}
id="artist-subfolder" onValueChange={(value: FilenamePreset) => {
checked={tempSettings.artistSubfolder} const preset = FILENAME_PRESETS[value];
onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, artistSubfolder: checked as boolean }))} setTempSettings(prev => ({
...prev,
filenamePreset: value,
filenameTemplate: value === "custom" ? prev.filenameTemplate : preset.template
}));
}}
>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(FILENAME_PRESETS).map(([key, { label }]) => (
<SelectItem key={key} value={key}>{label}</SelectItem>
))}
</SelectContent>
</Select>
{tempSettings.filenamePreset === "custom" && (
<InputWithContext
value={tempSettings.filenameTemplate}
onChange={(e) => setTempSettings(prev => ({ ...prev, filenameTemplate: e.target.value }))}
placeholder="{track}. {title}"
className="h-9 text-sm"
/> />
<Label htmlFor="artist-subfolder" className="cursor-pointer text-sm">Artist Subfolder</Label> )}
<Tooltip> {tempSettings.filenameTemplate && (
<TooltipTrigger asChild> <p className="text-xs text-muted-foreground">
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" /> Preview: <span className="font-mono">{tempSettings.filenameTemplate.replace(/\{artist\}/g, "Taylor Swift").replace(/\{title\}/g, "Shake It Off").replace(/\{track\}/g, "01").replace(/\{year\}/g, "2014")}.flac</span>
</TooltipTrigger> </p>
<TooltipContent side="top" className="max-w-xs"> )}
<p className="text-xs text-center">Playlist only</p>
</TooltipContent>
</Tooltip>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="album-subfolder"
checked={tempSettings.albumSubfolder}
onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, albumSubfolder: checked as boolean }))}
/>
<Label htmlFor="album-subfolder" className="cursor-pointer text-sm">Album Subfolder</Label>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
<p className="text-xs text-center">Playlist & Discography</p>
</TooltipContent>
</Tooltip>
</div>
</div> </div>
<div className="border-t" /> <div className="border-t" />
+82 -62
View File
@@ -9,12 +9,11 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Checkbox } from "@/components/ui/checkbox";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { FolderOpen, Save, RotateCcw, Info, Volume2 } from "lucide-react"; import { FolderOpen, Save, RotateCcw, Info, Volume2 } from "lucide-react";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { getSettings, getSettingsWithDefaults, saveSettings, resetToDefaultSettings, applyThemeMode, applyFont, FONT_OPTIONS, type Settings as SettingsType, type FontFamily } from "@/lib/settings"; import { getSettings, getSettingsWithDefaults, saveSettings, resetToDefaultSettings, applyThemeMode, applyFont, FONT_OPTIONS, FOLDER_PRESETS, FILENAME_PRESETS, TEMPLATE_VARIABLES, type Settings as SettingsType, type FontFamily, type FolderPreset, type FilenamePreset } from "@/lib/settings";
import { themes, applyTheme } from "@/lib/themes"; import { themes, applyTheme } from "@/lib/themes";
import { SelectFolder } from "../../wailsjs/go/main/App"; import { SelectFolder } from "../../wailsjs/go/main/App";
import { toastWithSound as toast } from "@/lib/toast-with-sound"; import { toastWithSound as toast } from "@/lib/toast-with-sound";
@@ -237,81 +236,102 @@ export function SettingsPage() {
{/* Right Column */} {/* Right Column */}
<div className="space-y-4"> <div className="space-y-4">
{/* Filename Format */} {/* Folder Structure */}
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-sm">Filename Format</Label> <div className="flex items-center gap-2">
<RadioGroup <Label className="text-sm">Folder Structure</Label>
value={tempSettings.filenameFormat} <Tooltip>
onValueChange={(value) => setTempSettings(prev => ({ ...prev, filenameFormat: value as any }))} <TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent side="top">
<p className="text-xs whitespace-nowrap">Variables: {TEMPLATE_VARIABLES.map(v => v.key).join(", ")}</p>
</TooltipContent>
</Tooltip>
</div>
<Select
value={tempSettings.folderPreset}
onValueChange={(value: FolderPreset) => {
const preset = FOLDER_PRESETS[value];
setTempSettings(prev => ({
...prev,
folderPreset: value,
folderTemplate: value === "custom" ? prev.folderTemplate : preset.template
}));
}}
> >
<div className="flex items-center space-x-2"> <SelectTrigger className="h-9">
<RadioGroupItem value="title-artist" id="title-artist" /> <SelectValue />
<Label htmlFor="title-artist" className="cursor-pointer font-normal text-sm">Title - Artist</Label> </SelectTrigger>
</div> <SelectContent>
<div className="flex items-center space-x-2"> {Object.entries(FOLDER_PRESETS).map(([key, { label }]) => (
<RadioGroupItem value="artist-title" id="artist-title" /> <SelectItem key={key} value={key}>{label}</SelectItem>
<Label htmlFor="artist-title" className="cursor-pointer font-normal text-sm">Artist - Title</Label> ))}
</div> </SelectContent>
<div className="flex items-center space-x-2"> </Select>
<RadioGroupItem value="title" id="title" /> {tempSettings.folderPreset === "custom" && (
<Label htmlFor="title" className="cursor-pointer font-normal text-sm">Title</Label> <InputWithContext
</div> value={tempSettings.folderTemplate}
</RadioGroup> onChange={(e) => setTempSettings(prev => ({ ...prev, folderTemplate: e.target.value }))}
placeholder="{artist}/{album}"
className="h-9 text-sm"
/>
)}
{tempSettings.folderTemplate && (
<p className="text-xs text-muted-foreground">
Preview: <span className="font-mono">{tempSettings.folderTemplate.replace(/\{artist\}/g, "Taylor Swift").replace(/\{album\}/g, "1989").replace(/\{year\}/g, "2014")}/</span>
</p>
)}
</div> </div>
<div className="border-t pt-4" /> <div className="border-t pt-4" />
{/* Folder Settings */} {/* Filename Format */}
<div className="space-y-2"> <div className="space-y-2">
<h3 className="font-medium text-sm">Folder Settings</h3>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Checkbox <Label className="text-sm">Filename Format</Label>
id="track-number"
checked={tempSettings.trackNumber}
onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, trackNumber: checked as boolean }))}
/>
<Label htmlFor="track-number" className="cursor-pointer text-sm">Track Number</Label>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" /> <Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top" className="max-w-xs"> <TooltipContent side="top">
<p className="text-xs text-center">Adds track numbers to filenames</p> <p className="text-xs whitespace-nowrap">Variables: {TEMPLATE_VARIABLES.map(v => v.key).join(", ")}</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
<div className="flex items-center gap-2"> <Select
<Checkbox value={tempSettings.filenamePreset}
id="artist-subfolder" onValueChange={(value: FilenamePreset) => {
checked={tempSettings.artistSubfolder} const preset = FILENAME_PRESETS[value];
onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, artistSubfolder: checked as boolean }))} setTempSettings(prev => ({
...prev,
filenamePreset: value,
filenameTemplate: value === "custom" ? prev.filenameTemplate : preset.template
}));
}}
>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(FILENAME_PRESETS).map(([key, { label }]) => (
<SelectItem key={key} value={key}>{label}</SelectItem>
))}
</SelectContent>
</Select>
{tempSettings.filenamePreset === "custom" && (
<InputWithContext
value={tempSettings.filenameTemplate}
onChange={(e) => setTempSettings(prev => ({ ...prev, filenameTemplate: e.target.value }))}
placeholder="{track}. {title}"
className="h-9 text-sm"
/> />
<Label htmlFor="artist-subfolder" className="cursor-pointer text-sm">Artist Subfolder</Label> )}
<Tooltip> {tempSettings.filenameTemplate && (
<TooltipTrigger asChild> <p className="text-xs text-muted-foreground">
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" /> Preview: <span className="font-mono">{tempSettings.filenameTemplate.replace(/\{artist\}/g, "Taylor Swift").replace(/\{title\}/g, "Shake It Off").replace(/\{track\}/g, "01").replace(/\{year\}/g, "2014")}.flac</span>
</TooltipTrigger> </p>
<TooltipContent side="top" className="max-w-xs"> )}
<p className="text-xs text-center">Playlist only</p>
</TooltipContent>
</Tooltip>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="album-subfolder"
checked={tempSettings.albumSubfolder}
onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, albumSubfolder: checked as boolean }))}
/>
<Label htmlFor="album-subfolder" className="cursor-pointer text-sm">Album Subfolder</Label>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
<p className="text-xs text-center">Playlist & Discography</p>
</TooltipContent>
</Tooltip>
</div>
</div> </div>
<div className="border-t pt-4" /> <div className="border-t pt-4" />
+2 -2
View File
@@ -49,7 +49,7 @@ interface TrackListProps {
downloadingCoverTrack?: string | null; downloadingCoverTrack?: string | null;
onToggleTrack: (isrc: string) => void; onToggleTrack: (isrc: string) => void;
onToggleSelectAll: (tracks: TrackMetadata[]) => void; onToggleSelectAll: (tracks: TrackMetadata[]) => void;
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, isArtistDiscography?: boolean) => void; onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, durationMs?: number, position?: number) => void;
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number) => void; onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number) => void;
onCheckAvailability?: (spotifyId: string, isrc?: string) => void; onCheckAvailability?: (spotifyId: string, isrc?: string) => void;
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string) => void; onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string) => void;
@@ -301,7 +301,7 @@ export function TrackList({
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
onClick={() => onClick={() =>
onDownloadTrack(track.isrc, track.name, track.artists, track.album_name, track.spotify_id, folderName, isArtistDiscography) onDownloadTrack(track.isrc, track.name, track.artists, track.album_name, track.spotify_id, folderName, track.duration_ms, startIndex + index + 1)
} }
size="sm" size="sm"
disabled={isDownloading || downloadingTrack === track.isrc} disabled={isDownloading || downloadingTrack === track.isrc}
+40 -27
View File
@@ -1,6 +1,6 @@
import { useState, useRef } from "react"; import { useState, useRef } from "react";
import { downloadCover } from "@/lib/api"; import { downloadCover } from "@/lib/api";
import { getSettings } from "@/lib/settings"; import { getSettings, parseTemplate, type TemplateData } from "@/lib/settings";
import { toastWithSound as toast } from "@/lib/toast-with-sound"; import { toastWithSound as toast } from "@/lib/toast-with-sound";
import { joinPath, sanitizePath } from "@/lib/utils"; import { joinPath, sanitizePath } from "@/lib/utils";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
@@ -22,7 +22,6 @@ export function useCover() {
artistName: string, artistName: string,
albumName?: string, albumName?: string,
playlistName?: string, playlistName?: string,
isArtistDiscography?: boolean,
position?: number, position?: number,
trackId?: string trackId?: string
) => { ) => {
@@ -41,20 +40,27 @@ export function useCover() {
const os = settings.operatingSystem; const os = settings.operatingSystem;
let outputDir = settings.downloadPath; let outputDir = settings.downloadPath;
// Build output path similar to audio download // Build output path using template system
const templateData: TemplateData = {
artist: artistName,
album: albumName,
title: trackName,
track: position,
playlist: playlistName,
};
// For playlist/discography, prepend the folder name
if (playlistName) { if (playlistName) {
outputDir = joinPath(os, outputDir, sanitizePath(playlistName, os)); outputDir = joinPath(os, outputDir, sanitizePath(playlistName, os));
}
if (isArtistDiscography) { // Apply folder template
if (settings.albumSubfolder && albumName) { if (settings.folderTemplate) {
outputDir = joinPath(os, outputDir, sanitizePath(albumName, os)); const folderPath = parseTemplate(settings.folderTemplate, templateData);
} if (folderPath) {
} else { const parts = folderPath.split("/").filter((p: string) => p.trim());
if (settings.artistSubfolder && artistName) { for (const part of parts) {
outputDir = joinPath(os, outputDir, sanitizePath(artistName, os)); outputDir = joinPath(os, outputDir, sanitizePath(part, os));
}
if (settings.albumSubfolder && albumName) {
outputDir = joinPath(os, outputDir, sanitizePath(albumName, os));
} }
} }
} }
@@ -64,7 +70,7 @@ export function useCover() {
track_name: trackName, track_name: trackName,
artist_name: artistName, artist_name: artistName,
output_dir: outputDir, output_dir: outputDir,
filename_format: settings.filenameFormat, filename_format: settings.filenameTemplate || "{title}",
track_number: settings.trackNumber, track_number: settings.trackNumber,
position: position || 0, position: position || 0,
}); });
@@ -97,8 +103,7 @@ export function useCover() {
const handleDownloadAllCovers = async ( const handleDownloadAllCovers = async (
tracks: TrackMetadata[], tracks: TrackMetadata[],
playlistName?: string, playlistName?: string
isArtistDiscography?: boolean
) => { ) => {
if (tracks.length === 0) { if (tracks.length === 0) {
toast.error("No tracks to download covers"); toast.error("No tracks to download covers");
@@ -135,19 +140,27 @@ export function useCover() {
const os = settings.operatingSystem; const os = settings.operatingSystem;
let outputDir = settings.downloadPath; let outputDir = settings.downloadPath;
// Build output path using template system
const templateData: TemplateData = {
artist: track.artists,
album: track.album_name,
title: track.name,
track: i + 1,
playlist: playlistName,
};
// For playlist/discography, prepend the folder name
if (playlistName) { if (playlistName) {
outputDir = joinPath(os, outputDir, sanitizePath(playlistName, os)); outputDir = joinPath(os, outputDir, sanitizePath(playlistName, os));
}
if (isArtistDiscography) { // Apply folder template
if (settings.albumSubfolder && track.album_name) { if (settings.folderTemplate) {
outputDir = joinPath(os, outputDir, sanitizePath(track.album_name, os)); const folderPath = parseTemplate(settings.folderTemplate, templateData);
} if (folderPath) {
} else { const parts = folderPath.split("/").filter((p: string) => p.trim());
if (settings.artistSubfolder && track.artists) { for (const part of parts) {
outputDir = joinPath(os, outputDir, sanitizePath(track.artists, os)); outputDir = joinPath(os, outputDir, sanitizePath(part, os));
}
if (settings.albumSubfolder && track.album_name) {
outputDir = joinPath(os, outputDir, sanitizePath(track.album_name, os));
} }
} }
} }
@@ -157,7 +170,7 @@ export function useCover() {
track_name: track.name, track_name: track.name,
artist_name: track.artists, artist_name: track.artists,
output_dir: outputDir, output_dir: outputDir,
filename_format: settings.filenameFormat, filename_format: settings.filenameTemplate || "{title}",
track_number: settings.trackNumber, track_number: settings.trackNumber,
position: i + 1, position: i + 1,
}); });
+91 -57
View File
@@ -1,6 +1,6 @@
import { useState, useRef } from "react"; import { useState, useRef } from "react";
import { downloadTrack } from "@/lib/api"; import { downloadTrack } from "@/lib/api";
import { getSettings } from "@/lib/settings"; import { getSettings, parseTemplate, type TemplateData } from "@/lib/settings";
import { toastWithSound as toast } from "@/lib/toast-with-sound"; import { toastWithSound as toast } from "@/lib/toast-with-sound";
import { joinPath, sanitizePath } from "@/lib/utils"; import { joinPath, sanitizePath } from "@/lib/utils";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
@@ -27,10 +27,10 @@ export function useDownload() {
artistName?: string, artistName?: string,
albumName?: string, albumName?: string,
playlistName?: string, playlistName?: string,
isArtistDiscography?: boolean,
position?: number, position?: number,
spotifyId?: string, spotifyId?: string,
durationMs?: number durationMs?: number,
releaseYear?: string
) => { ) => {
let service = settings.downloader; let service = settings.downloader;
@@ -40,24 +40,36 @@ export function useDownload() {
let outputDir = settings.downloadPath; let outputDir = settings.downloadPath;
let useAlbumTrackNumber = false; let useAlbumTrackNumber = false;
// Build template data for folder path
const templateData: TemplateData = {
artist: artistName,
album: albumName,
title: trackName,
track: position,
year: releaseYear,
playlist: playlistName,
isrc: isrc,
};
// For playlist/discography downloads, always create a folder with the playlist/artist name
if (playlistName) { if (playlistName) {
outputDir = joinPath(os, outputDir, sanitizePath(playlistName, os)); outputDir = joinPath(os, outputDir, sanitizePath(playlistName, os));
}
if (isArtistDiscography) { // Apply folder template if available
if (settings.albumSubfolder && albumName) { if (settings.folderTemplate) {
outputDir = joinPath(os, outputDir, sanitizePath(albumName, os)); const folderPath = parseTemplate(settings.folderTemplate, templateData);
useAlbumTrackNumber = true; // Use album track number for discography with album subfolder if (folderPath) {
} const parts = folderPath.split("/").filter((p: string) => p.trim());
} else { for (const part of parts) {
if (settings.artistSubfolder && artistName) { outputDir = joinPath(os, outputDir, sanitizePath(part, os));
outputDir = joinPath(os, outputDir, sanitizePath(artistName, os));
}
if (settings.albumSubfolder && albumName) {
outputDir = joinPath(os, outputDir, sanitizePath(albumName, os));
useAlbumTrackNumber = true; // Use album track number when both artist and album subfolders are used
} }
} }
// Use album track number if template contains {album}
if (settings.folderTemplate.includes("{album}")) {
useAlbumTrackNumber = true;
}
} }
// Always add item to queue before downloading // Always add item to queue before downloading
@@ -92,7 +104,7 @@ export function useDownload() {
artist_name: artistName, artist_name: artistName,
album_name: albumName, album_name: albumName,
output_dir: outputDir, output_dir: outputDir,
filename_format: settings.filenameFormat, filename_format: settings.filenameTemplate,
track_number: settings.trackNumber, track_number: settings.trackNumber,
position, position,
use_album_track_number: useAlbumTrackNumber, use_album_track_number: useAlbumTrackNumber,
@@ -124,7 +136,7 @@ export function useDownload() {
artist_name: artistName, artist_name: artistName,
album_name: albumName, album_name: albumName,
output_dir: outputDir, output_dir: outputDir,
filename_format: settings.filenameFormat, filename_format: settings.filenameTemplate,
track_number: settings.trackNumber, track_number: settings.trackNumber,
position, position,
use_album_track_number: useAlbumTrackNumber, use_album_track_number: useAlbumTrackNumber,
@@ -155,7 +167,7 @@ export function useDownload() {
artist_name: artistName, artist_name: artistName,
album_name: albumName, album_name: albumName,
output_dir: outputDir, output_dir: outputDir,
filename_format: settings.filenameFormat, filename_format: settings.filenameTemplate,
track_number: settings.trackNumber, track_number: settings.trackNumber,
position, position,
use_album_track_number: useAlbumTrackNumber, use_album_track_number: useAlbumTrackNumber,
@@ -184,7 +196,7 @@ export function useDownload() {
artist_name: artistName, artist_name: artistName,
album_name: albumName, album_name: albumName,
output_dir: outputDir, output_dir: outputDir,
filename_format: settings.filenameFormat, filename_format: settings.filenameTemplate,
track_number: settings.trackNumber, track_number: settings.trackNumber,
position, position,
use_album_track_number: useAlbumTrackNumber, use_album_track_number: useAlbumTrackNumber,
@@ -214,7 +226,7 @@ export function useDownload() {
artist_name: artistName, artist_name: artistName,
album_name: albumName, album_name: albumName,
output_dir: outputDir, output_dir: outputDir,
filename_format: settings.filenameFormat, filename_format: settings.filenameTemplate,
track_number: settings.trackNumber, track_number: settings.trackNumber,
position, position,
use_album_track_number: useAlbumTrackNumber, use_album_track_number: useAlbumTrackNumber,
@@ -239,11 +251,12 @@ export function useDownload() {
trackName?: string, trackName?: string,
artistName?: string, artistName?: string,
albumName?: string, albumName?: string,
playlistName?: string, folderName?: string,
isArtistDiscography?: boolean,
position?: number, position?: number,
spotifyId?: string, spotifyId?: string,
durationMs?: number durationMs?: number,
isAlbum?: boolean,
releaseYear?: string
) => { ) => {
let service = settings.downloader; let service = settings.downloader;
@@ -253,24 +266,38 @@ export function useDownload() {
let outputDir = settings.downloadPath; let outputDir = settings.downloadPath;
let useAlbumTrackNumber = false; let useAlbumTrackNumber = false;
if (playlistName) { // Build template data for folder path
outputDir = joinPath(os, outputDir, sanitizePath(playlistName, os)); const templateData: TemplateData = {
artist: artistName,
album: albumName,
title: trackName,
track: position,
year: releaseYear,
playlist: folderName,
isrc: isrc,
};
if (isArtistDiscography) { // For playlist/discography downloads, always create a folder with the playlist/artist name
if (settings.albumSubfolder && albumName) { if (folderName && !isAlbum) {
outputDir = joinPath(os, outputDir, sanitizePath(albumName, os)); outputDir = joinPath(os, outputDir, sanitizePath(folderName, os));
useAlbumTrackNumber = true; }
}
} else {
if (settings.artistSubfolder && artistName) {
outputDir = joinPath(os, outputDir, sanitizePath(artistName, os));
}
if (settings.albumSubfolder && albumName) { // Apply folder template if available
outputDir = joinPath(os, outputDir, sanitizePath(albumName, os)); if (settings.folderTemplate) {
useAlbumTrackNumber = true; // Parse and apply folder template
const folderPath = parseTemplate(settings.folderTemplate, templateData);
if (folderPath) {
// Split by / and sanitize each part
const parts = folderPath.split("/").filter(p => p.trim());
for (const part of parts) {
outputDir = joinPath(os, outputDir, sanitizePath(part, os));
} }
} }
// Use album track number if template contains {album}
if (settings.folderTemplate.includes("{album}")) {
useAlbumTrackNumber = true;
}
} }
if (service === "auto") { if (service === "auto") {
@@ -299,7 +326,7 @@ export function useDownload() {
artist_name: artistName, artist_name: artistName,
album_name: albumName, album_name: albumName,
output_dir: outputDir, output_dir: outputDir,
filename_format: settings.filenameFormat, filename_format: settings.filenameTemplate,
track_number: settings.trackNumber, track_number: settings.trackNumber,
position, position,
use_album_track_number: useAlbumTrackNumber, use_album_track_number: useAlbumTrackNumber,
@@ -328,7 +355,7 @@ export function useDownload() {
artist_name: artistName, artist_name: artistName,
album_name: albumName, album_name: albumName,
output_dir: outputDir, output_dir: outputDir,
filename_format: settings.filenameFormat, filename_format: settings.filenameTemplate,
track_number: settings.trackNumber, track_number: settings.trackNumber,
position, position,
use_album_track_number: useAlbumTrackNumber, use_album_track_number: useAlbumTrackNumber,
@@ -356,7 +383,7 @@ export function useDownload() {
artist_name: artistName, artist_name: artistName,
album_name: albumName, album_name: albumName,
output_dir: outputDir, output_dir: outputDir,
filename_format: settings.filenameFormat, filename_format: settings.filenameTemplate,
track_number: settings.trackNumber, track_number: settings.trackNumber,
position, position,
use_album_track_number: useAlbumTrackNumber, use_album_track_number: useAlbumTrackNumber,
@@ -382,7 +409,7 @@ export function useDownload() {
artist_name: artistName, artist_name: artistName,
album_name: albumName, album_name: albumName,
output_dir: outputDir, output_dir: outputDir,
filename_format: settings.filenameFormat, filename_format: settings.filenameTemplate,
track_number: settings.trackNumber, track_number: settings.trackNumber,
position, position,
use_album_track_number: useAlbumTrackNumber, use_album_track_number: useAlbumTrackNumber,
@@ -411,7 +438,7 @@ export function useDownload() {
artist_name: artistName, artist_name: artistName,
album_name: albumName, album_name: albumName,
output_dir: outputDir, output_dir: outputDir,
filename_format: settings.filenameFormat, filename_format: settings.filenameTemplate,
track_number: settings.trackNumber, track_number: settings.trackNumber,
position, position,
use_album_track_number: useAlbumTrackNumber, use_album_track_number: useAlbumTrackNumber,
@@ -436,8 +463,8 @@ export function useDownload() {
albumName?: string, albumName?: string,
spotifyId?: string, spotifyId?: string,
playlistName?: string, playlistName?: string,
isArtistDiscography?: boolean, durationMs?: number,
durationMs?: number position?: number
) => { ) => {
if (!isrc) { if (!isrc) {
toast.error("No ISRC found for this track"); toast.error("No ISRC found for this track");
@@ -457,8 +484,7 @@ export function useDownload() {
artistName, artistName,
albumName, albumName,
playlistName, playlistName,
isArtistDiscography, position, // Pass position for track numbering
undefined, // Don't pass position for single track
spotifyId, spotifyId,
durationMs durationMs
); );
@@ -491,8 +517,8 @@ export function useDownload() {
const handleDownloadSelected = async ( const handleDownloadSelected = async (
selectedTracks: string[], selectedTracks: string[],
allTracks: TrackMetadata[], allTracks: TrackMetadata[],
playlistName?: string, folderName?: string,
isArtistDiscography?: boolean isAlbum?: boolean
) => { ) => {
if (selectedTracks.length === 0) { if (selectedTracks.length === 0) {
toast.error("No tracks selected"); toast.error("No tracks selected");
@@ -543,6 +569,9 @@ export function useDownload() {
} }
try { try {
// Extract year from release_date (format: YYYY-MM-DD or YYYY)
const releaseYear = track?.release_date?.substring(0, 4);
// Download with pre-created itemID // Download with pre-created itemID
const response = await downloadWithItemID( const response = await downloadWithItemID(
isrc, isrc,
@@ -551,11 +580,12 @@ export function useDownload() {
track?.name, track?.name,
track?.artists, track?.artists,
track?.album_name, track?.album_name,
playlistName, folderName,
isArtistDiscography,
i + 1, // Sequential position based on selection order i + 1, // Sequential position based on selection order
track?.spotify_id, track?.spotify_id,
track?.duration_ms track?.duration_ms,
isAlbum,
releaseYear
); );
if (response.success) { if (response.success) {
@@ -622,8 +652,8 @@ export function useDownload() {
const handleDownloadAll = async ( const handleDownloadAll = async (
tracks: TrackMetadata[], tracks: TrackMetadata[],
playlistName?: string, folderName?: string,
isArtistDiscography?: boolean isAlbum?: boolean
) => { ) => {
const tracksWithIsrc = tracks.filter((track) => track.isrc); const tracksWithIsrc = tracks.filter((track) => track.isrc);
@@ -671,6 +701,9 @@ export function useDownload() {
setCurrentDownloadInfo({ name: track.name, artists: track.artists }); setCurrentDownloadInfo({ name: track.name, artists: track.artists });
try { try {
// Extract year from release_date (format: YYYY-MM-DD or YYYY)
const releaseYear = track.release_date?.substring(0, 4);
const response = await downloadWithItemID( const response = await downloadWithItemID(
track.isrc, track.isrc,
settings, settings,
@@ -678,11 +711,12 @@ export function useDownload() {
track.name, track.name,
track.artists, track.artists,
track.album_name, track.album_name,
playlistName, folderName,
isArtistDiscography,
i + 1, i + 1,
track.spotify_id, track.spotify_id,
track.duration_ms track.duration_ms,
isAlbum,
releaseYear
); );
if (response.success) { if (response.success) {
+23 -15
View File
@@ -1,6 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { downloadLyrics } from "@/lib/api"; import { downloadLyrics } from "@/lib/api";
import { getSettings } from "@/lib/settings"; import { getSettings, parseTemplate, type TemplateData } from "@/lib/settings";
import { toastWithSound as toast } from "@/lib/toast-with-sound"; import { toastWithSound as toast } from "@/lib/toast-with-sound";
import { joinPath, sanitizePath } from "@/lib/utils"; import { joinPath, sanitizePath } from "@/lib/utils";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
@@ -17,7 +17,6 @@ export function useLyrics() {
artistName: string, artistName: string,
albumName?: string, albumName?: string,
playlistName?: string, playlistName?: string,
isArtistDiscography?: boolean,
position?: number position?: number
) => { ) => {
if (!spotifyId) { if (!spotifyId) {
@@ -33,33 +32,42 @@ export function useLyrics() {
const os = settings.operatingSystem; const os = settings.operatingSystem;
let outputDir = settings.downloadPath; let outputDir = settings.downloadPath;
// Build output path similar to audio download // Build output path using template system
const templateData: TemplateData = {
artist: artistName,
album: albumName,
title: trackName,
track: position,
playlist: playlistName,
};
// For playlist/discography, prepend the folder name
if (playlistName) { if (playlistName) {
outputDir = joinPath(os, outputDir, sanitizePath(playlistName, os)); outputDir = joinPath(os, outputDir, sanitizePath(playlistName, os));
}
if (isArtistDiscography) { // Apply folder template
if (settings.albumSubfolder && albumName) { if (settings.folderTemplate) {
outputDir = joinPath(os, outputDir, sanitizePath(albumName, os)); const folderPath = parseTemplate(settings.folderTemplate, templateData);
} if (folderPath) {
} else { const parts = folderPath.split("/").filter((p: string) => p.trim());
if (settings.artistSubfolder && artistName) { for (const part of parts) {
outputDir = joinPath(os, outputDir, sanitizePath(artistName, os)); outputDir = joinPath(os, outputDir, sanitizePath(part, os));
}
if (settings.albumSubfolder && albumName) {
outputDir = joinPath(os, outputDir, sanitizePath(albumName, os));
} }
} }
} }
const useAlbumTrackNumber = settings.folderTemplate?.includes("{album}") || false;
const response = await downloadLyrics({ const response = await downloadLyrics({
spotify_id: spotifyId, spotify_id: spotifyId,
track_name: trackName, track_name: trackName,
artist_name: artistName, artist_name: artistName,
output_dir: outputDir, output_dir: outputDir,
filename_format: settings.filenameFormat, filename_format: settings.filenameTemplate || "{title}",
track_number: settings.trackNumber, track_number: settings.trackNumber,
position: position || 0, position: position || 0,
use_album_track_number: settings.albumSubfolder, use_album_track_number: useAlbumTrackNumber,
}); });
if (response.success) { if (response.success) {
+110 -6
View File
@@ -2,20 +2,64 @@ import { GetDefaults } from "../../wailsjs/go/main/App";
export type FontFamily = "google-sans" | "inter" | "poppins" | "roboto" | "dm-sans" | "plus-jakarta-sans" | "manrope" | "space-grotesk"; export type FontFamily = "google-sans" | "inter" | "poppins" | "roboto" | "dm-sans" | "plus-jakarta-sans" | "manrope" | "space-grotesk";
// Folder structure presets
export type FolderPreset = "none" | "artist" | "album" | "artist-album" | "artist-year-album" | "custom";
// Filename format presets
export type FilenamePreset = "title" | "title-artist" | "artist-title" | "track-title" | "track-title-artist" | "track-artist-title" | "custom";
export interface Settings { export interface Settings {
downloadPath: string; downloadPath: string;
downloader: "auto" | "deezer" | "tidal" | "qobuz" | "amazon"; downloader: "auto" | "deezer" | "tidal" | "qobuz" | "amazon";
theme: string; theme: string;
themeMode: "auto" | "light" | "dark"; themeMode: "auto" | "light" | "dark";
fontFamily: FontFamily; fontFamily: FontFamily;
filenameFormat: "title-artist" | "artist-title" | "title"; // New template system
artistSubfolder: boolean; folderPreset: FolderPreset;
albumSubfolder: boolean; folderTemplate: string;
filenamePreset: FilenamePreset;
filenameTemplate: string;
// Legacy settings (kept for migration)
filenameFormat?: "title-artist" | "artist-title" | "title";
artistSubfolder?: boolean;
albumSubfolder?: boolean;
trackNumber: boolean; trackNumber: boolean;
sfxEnabled: boolean; sfxEnabled: boolean;
operatingSystem: "Windows" | "linux/MacOS" operatingSystem: "Windows" | "linux/MacOS"
} }
// Folder preset templates
export const FOLDER_PRESETS: Record<FolderPreset, { label: string; template: string }> = {
"none": { label: "No Subfolder", template: "" },
"artist": { label: "Artist", template: "{artist}" },
"album": { label: "Album", template: "{album}" },
"artist-album": { label: "Artist / Album", template: "{artist}/{album}" },
"artist-year-album": { label: "Artist / [Year] Album", template: "{artist}/[{year}] {album}" },
"custom": { label: "Custom...", template: "" },
};
// Filename preset templates
export const FILENAME_PRESETS: Record<FilenamePreset, { label: string; template: string }> = {
"title": { label: "Title", template: "{title}" },
"title-artist": { label: "Title - Artist", template: "{title} - {artist}" },
"artist-title": { label: "Artist - Title", template: "{artist} - {title}" },
"track-title": { label: "Track. Title", template: "{track}. {title}" },
"track-title-artist": { label: "Track. Title - Artist", template: "{track}. {title} - {artist}" },
"track-artist-title": { label: "Track. Artist - Title", template: "{track}. {artist} - {title}" },
"custom": { label: "Custom...", template: "" },
};
// Available template variables
export const TEMPLATE_VARIABLES = [
{ key: "{artist}", description: "Artist name", example: "Taylor Swift" },
{ key: "{album}", description: "Album name", example: "1989" },
{ key: "{title}", description: "Track title", example: "Shake It Off" },
{ key: "{track}", description: "Track number", example: "01" },
{ key: "{year}", description: "Release year", example: "2014" },
{ key: "{isrc}", description: "ISRC code", example: "USCJY1431309" },
{ key: "{playlist}", description: "Playlist name", example: "My Playlist" },
];
// Auto-detect operating system // Auto-detect operating system
function detectOS(): "Windows" | "linux/MacOS" { function detectOS(): "Windows" | "linux/MacOS" {
const platform = window.navigator.platform.toLowerCase(); const platform = window.navigator.platform.toLowerCase();
@@ -31,9 +75,10 @@ export const DEFAULT_SETTINGS: Settings = {
theme: "yellow", theme: "yellow",
themeMode: "auto", themeMode: "auto",
fontFamily: "google-sans", fontFamily: "google-sans",
filenameFormat: "title-artist", folderPreset: "none",
artistSubfolder: false, folderTemplate: "",
albumSubfolder: false, filenamePreset: "title-artist",
filenameTemplate: "{title} - {artist}",
trackNumber: false, trackNumber: false,
sfxEnabled: true, sfxEnabled: true,
operatingSystem: detectOS() operatingSystem: detectOS()
@@ -80,6 +125,37 @@ export function getSettings(): Settings {
parsed.themeMode = parsed.darkMode ? 'dark' : 'light'; parsed.themeMode = parsed.darkMode ? 'dark' : 'light';
delete parsed.darkMode; delete parsed.darkMode;
} }
// Migrate old folder/filename settings to new template system
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}";
}
}
// Always use detected OS (don't persist it) // Always use detected OS (don't persist it)
parsed.operatingSystem = detectOS(); parsed.operatingSystem = detectOS();
return { ...DEFAULT_SETTINGS, ...parsed }; return { ...DEFAULT_SETTINGS, ...parsed };
@@ -90,6 +166,34 @@ export function getSettings(): Settings {
return DEFAULT_SETTINGS; return DEFAULT_SETTINGS;
} }
// Parse template and replace variables with actual values
export interface TemplateData {
artist?: string;
album?: string;
title?: string;
track?: number;
year?: string;
isrc?: string;
playlist?: string;
}
export function parseTemplate(template: string, data: TemplateData): string {
if (!template) return "";
let result = template;
// Replace each variable
result = result.replace(/\{artist\}/g, data.artist || "Unknown Artist");
result = result.replace(/\{album\}/g, data.album || "Unknown Album");
result = result.replace(/\{title\}/g, data.title || "Unknown Title");
result = result.replace(/\{track\}/g, data.track ? String(data.track).padStart(2, "0") : "00");
result = result.replace(/\{year\}/g, data.year || "0000");
result = result.replace(/\{isrc\}/g, data.isrc || "");
result = result.replace(/\{playlist\}/g, data.playlist || "");
return result;
}
export async function getSettingsWithDefaults(): Promise<Settings> { export async function getSettingsWithDefaults(): Promise<Settings> {
const settings = getSettings(); const settings = getSettings();
+14 -2
View File
@@ -6,5 +6,17 @@
"eu-katze.qqdl.site", "eu-katze.qqdl.site",
"katze.qqdl.site", "katze.qqdl.site",
"wolf.qqdl.site", "wolf.qqdl.site",
"tidal.kinoplus.online" "tidal.kinoplus.online",
] "tidal-api.binimum.org",
"tidal-api-2.binimum.org",
"dev-api.squid.wtf",
"triton.squid.wtf",
"ohio.monochrome.tf",
"virginia.monochrome.tf",
"oregon.monochrome.tf",
"california.monochrome.tf",
"frankfurt.monochrome.tf",
"london.monochrome.tf",
"singapore.monochrome.tf",
"jakarta.monochrome.tf"
]
+1 -1
View File
@@ -1,3 +1,3 @@
{ {
"version": "6.6" "version": "6.7"
} }
+1 -1
View File
@@ -12,7 +12,7 @@
}, },
"info": { "info": {
"productName": "SpotiFLAC", "productName": "SpotiFLAC",
"productVersion": "6.6" "productVersion": "6.7"
}, },
"wailsjsdir": "./frontend", "wailsjsdir": "./frontend",
"assetdir": "./frontend/dist", "assetdir": "./frontend/dist",