v6.7
This commit is contained in:
@@ -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
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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, "&", "&")
|
||||||
|
mediaTemplate = strings.ReplaceAll(mediaTemplate, "&", "&")
|
||||||
|
|
||||||
|
// 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"
|
||||||
|
|||||||
@@ -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 @@
|
|||||||
b7a549e463d5f6a2fad25f5ce939cdd7
|
58400d5c8f7b03bac8ab784b5e775687
|
||||||
Generated
+406
-406
File diff suppressed because it is too large
Load Diff
+14
-14
@@ -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}
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,23 +40,35 @@ 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) {
|
// Use album track number if template contains {album}
|
||||||
outputDir = joinPath(os, outputDir, sanitizePath(albumName, os));
|
if (settings.folderTemplate.includes("{album}")) {
|
||||||
useAlbumTrackNumber = true; // Use album track number when both artist and album subfolders are used
|
useAlbumTrackNumber = true;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
+13
-1
@@ -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
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"version": "6.6"
|
"version": "6.7"
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user