mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-03-31 17:10:29 +02:00
- Add advanced filename template placeholders: {track_raw}, {disc_raw}, {date},
formatted numbers {track:N}/{disc:N}, and date formatting {date:%Y-%m-%d}
with strftime-to-Go layout conversion and robust date parser
- Pass date/release_date metadata to filename builder in all providers
(Amazon, Qobuz, Tidal, YouTube, extensions) and Flutter download queue
- Detect ARM32-only / low-RAM Android devices at startup and reduce image
cache size and disable overscroll effects for smoother experience
- Make artist screen selection bar responsive: compact stacked layout on
narrow screens or large text scale; add quality picker before track download
- Add advanced tags toggle in download settings filename format editor
- Fix ICU plural syntax in DE/ES/PT/RU translations (one {}=1{...} -> one {...})
- Add filenameShowAdvancedTags l10n strings (EN, ID) and regenerate dart files
- Fix featured-artist regex: remove '&' from split separators
- Add Go filename template tests (filename_test.go)
- Add GitHub Pages workflow and static project site
302 lines
6.2 KiB
Go
302 lines
6.2 KiB
Go
package gobackend
|
|
|
|
import (
|
|
"fmt"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
var (
|
|
invalidChars = regexp.MustCompile(`[<>:"/\\|?*\x00-\x1f]`)
|
|
multiUnderscore = regexp.MustCompile(`_+`)
|
|
formattedNumberPlaceholderExpr = regexp.MustCompile(`\{(track|disc):([0-9]+)\}`)
|
|
dateFormatPlaceholderExpr = regexp.MustCompile(`\{date:([^{}]+)\}`)
|
|
yearPattern = regexp.MustCompile(`\d{4}`)
|
|
)
|
|
|
|
func sanitizeFilename(filename string) string {
|
|
sanitized := invalidChars.ReplaceAllString(filename, "_")
|
|
|
|
sanitized = strings.TrimSpace(sanitized)
|
|
sanitized = strings.Trim(sanitized, ".")
|
|
|
|
sanitized = multiUnderscore.ReplaceAllString(sanitized, "_")
|
|
|
|
if len(sanitized) > 200 {
|
|
sanitized = sanitized[:200]
|
|
}
|
|
|
|
if sanitized == "" {
|
|
sanitized = "untitled"
|
|
}
|
|
|
|
return sanitized
|
|
}
|
|
|
|
func buildFilenameFromTemplate(template string, metadata map[string]interface{}) string {
|
|
if template == "" {
|
|
template = "{artist} - {title}"
|
|
}
|
|
|
|
result := replaceFormattedNumberPlaceholders(template, metadata)
|
|
result = replaceDateFormatPlaceholders(result, metadata)
|
|
|
|
dateValue := getDateValue(metadata)
|
|
yearValue := getString(metadata, "year")
|
|
if yearValue == "" {
|
|
yearValue = extractYear(dateValue)
|
|
}
|
|
|
|
placeholders := map[string]string{
|
|
"{title}": getString(metadata, "title"),
|
|
"{artist}": getString(metadata, "artist"),
|
|
"{album}": getString(metadata, "album"),
|
|
"{track}": formatTrackNumber(getInt(metadata, "track")),
|
|
"{track_raw}": formatRawNumber(getInt(metadata, "track")),
|
|
"{year}": yearValue,
|
|
"{date}": dateValue,
|
|
"{disc}": formatDiscNumber(getInt(metadata, "disc")),
|
|
"{disc_raw}": formatRawNumber(getInt(metadata, "disc")),
|
|
}
|
|
|
|
for placeholder, value := range placeholders {
|
|
result = strings.ReplaceAll(result, placeholder, value)
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
func replaceFormattedNumberPlaceholders(template string, metadata map[string]interface{}) string {
|
|
return formattedNumberPlaceholderExpr.ReplaceAllStringFunc(template, func(match string) string {
|
|
parts := formattedNumberPlaceholderExpr.FindStringSubmatch(match)
|
|
if len(parts) != 3 {
|
|
return ""
|
|
}
|
|
|
|
number := getInt(metadata, parts[1])
|
|
width, err := strconv.Atoi(parts[2])
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
|
|
return formatNumberWithWidth(number, width)
|
|
})
|
|
}
|
|
|
|
func replaceDateFormatPlaceholders(template string, metadata map[string]interface{}) string {
|
|
return dateFormatPlaceholderExpr.ReplaceAllStringFunc(template, func(match string) string {
|
|
parts := dateFormatPlaceholderExpr.FindStringSubmatch(match)
|
|
if len(parts) != 2 {
|
|
return ""
|
|
}
|
|
|
|
return formatDateWithPattern(getDateValue(metadata), parts[1])
|
|
})
|
|
}
|
|
|
|
func getDateValue(metadata map[string]interface{}) string {
|
|
date := getString(metadata, "date")
|
|
if date != "" {
|
|
return date
|
|
}
|
|
|
|
releaseDate := getString(metadata, "release_date")
|
|
if releaseDate != "" {
|
|
return releaseDate
|
|
}
|
|
|
|
return getString(metadata, "year")
|
|
}
|
|
|
|
func getString(m map[string]interface{}, key string) string {
|
|
if v, ok := m[key]; ok {
|
|
switch value := v.(type) {
|
|
case string:
|
|
return strings.TrimSpace(value)
|
|
case int:
|
|
return strconv.Itoa(value)
|
|
case int64:
|
|
return strconv.FormatInt(value, 10)
|
|
case float64:
|
|
return strconv.Itoa(int(value))
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func getInt(m map[string]interface{}, key string) int {
|
|
candidateKeys := []string{key}
|
|
switch key {
|
|
case "track":
|
|
candidateKeys = append(candidateKeys, "track_number")
|
|
case "disc":
|
|
candidateKeys = append(candidateKeys, "disc_number")
|
|
}
|
|
|
|
for _, candidate := range candidateKeys {
|
|
if v, ok := m[candidate]; ok {
|
|
switch n := v.(type) {
|
|
case int:
|
|
return n
|
|
case int64:
|
|
return int(n)
|
|
case float64:
|
|
return int(n)
|
|
case string:
|
|
parsed, err := strconv.Atoi(strings.TrimSpace(n))
|
|
if err == nil {
|
|
return parsed
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return 0
|
|
}
|
|
|
|
func formatTrackNumber(n int) string {
|
|
if n <= 0 {
|
|
return ""
|
|
}
|
|
return fmt.Sprintf("%02d", n)
|
|
}
|
|
|
|
func formatDiscNumber(n int) string {
|
|
if n <= 0 {
|
|
return ""
|
|
}
|
|
return fmt.Sprintf("%d", n)
|
|
}
|
|
|
|
func formatRawNumber(n int) string {
|
|
if n <= 0 {
|
|
return ""
|
|
}
|
|
return fmt.Sprintf("%d", n)
|
|
}
|
|
|
|
func formatNumberWithWidth(n int, width int) string {
|
|
if n <= 0 || width <= 0 {
|
|
return ""
|
|
}
|
|
if width <= 1 {
|
|
return formatRawNumber(n)
|
|
}
|
|
return fmt.Sprintf("%0*d", width, n)
|
|
}
|
|
|
|
func formatDateWithPattern(rawDate string, strftimePattern string) string {
|
|
if rawDate == "" || strftimePattern == "" {
|
|
return ""
|
|
}
|
|
|
|
parsedDate, ok := parseMetadataDate(rawDate)
|
|
if !ok {
|
|
return ""
|
|
}
|
|
|
|
goLayout := convertStrftimeToGoLayout(strftimePattern)
|
|
if goLayout == "" {
|
|
return ""
|
|
}
|
|
|
|
return parsedDate.Format(goLayout)
|
|
}
|
|
|
|
func parseMetadataDate(rawDate string) (time.Time, bool) {
|
|
clean := strings.TrimSpace(rawDate)
|
|
if clean == "" {
|
|
return time.Time{}, false
|
|
}
|
|
|
|
layouts := []string{
|
|
time.RFC3339Nano,
|
|
time.RFC3339,
|
|
"2006-01-02",
|
|
"2006-01",
|
|
"2006",
|
|
"2006/01/02",
|
|
"2006/01",
|
|
"2006.01.02",
|
|
"2006.01",
|
|
}
|
|
|
|
for _, layout := range layouts {
|
|
parsed, err := time.Parse(layout, clean)
|
|
if err == nil {
|
|
return parsed, true
|
|
}
|
|
}
|
|
|
|
if len(clean) >= 10 {
|
|
parsed, err := time.Parse("2006-01-02", clean[:10])
|
|
if err == nil {
|
|
return parsed, true
|
|
}
|
|
}
|
|
|
|
yearMatch := yearPattern.FindString(clean)
|
|
if yearMatch == "" {
|
|
return time.Time{}, false
|
|
}
|
|
|
|
year, err := strconv.Atoi(yearMatch)
|
|
if err != nil || year <= 0 {
|
|
return time.Time{}, false
|
|
}
|
|
|
|
return time.Date(year, time.January, 1, 0, 0, 0, 0, time.UTC), true
|
|
}
|
|
|
|
func convertStrftimeToGoLayout(pattern string) string {
|
|
if pattern == "" {
|
|
return ""
|
|
}
|
|
|
|
var builder strings.Builder
|
|
for i := 0; i < len(pattern); i++ {
|
|
ch := pattern[i]
|
|
if ch != '%' {
|
|
builder.WriteByte(ch)
|
|
continue
|
|
}
|
|
|
|
if i+1 >= len(pattern) {
|
|
builder.WriteByte('%')
|
|
break
|
|
}
|
|
|
|
i++
|
|
switch pattern[i] {
|
|
case 'Y':
|
|
builder.WriteString("2006")
|
|
case 'y':
|
|
builder.WriteString("06")
|
|
case 'm':
|
|
builder.WriteString("01")
|
|
case 'd':
|
|
builder.WriteString("02")
|
|
case 'b':
|
|
builder.WriteString("Jan")
|
|
case 'B':
|
|
builder.WriteString("January")
|
|
case '%':
|
|
builder.WriteByte('%')
|
|
default:
|
|
builder.WriteByte('%')
|
|
builder.WriteByte(pattern[i])
|
|
}
|
|
}
|
|
|
|
return builder.String()
|
|
}
|
|
|
|
func extractYear(date string) string {
|
|
if len(date) >= 4 {
|
|
return date[:4]
|
|
}
|
|
return date
|
|
}
|