v7.0.3
This commit is contained in:
@@ -336,6 +336,12 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
deezerISRC := req.ISRC
|
deezerISRC := req.ISRC
|
||||||
|
if deezerISRC != "" {
|
||||||
|
isrcValid := len(deezerISRC) == 12 && strings.Contains(deezerISRC, "-")
|
||||||
|
if !isrcValid {
|
||||||
|
deezerISRC = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
if deezerISRC == "" && req.SpotifyID != "" {
|
if deezerISRC == "" && req.SpotifyID != "" {
|
||||||
|
|
||||||
songlinkClient := backend.NewSongLinkClient()
|
songlinkClient := backend.NewSongLinkClient()
|
||||||
|
|||||||
+15
-5
@@ -12,6 +12,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
spotifySize300 = "ab67616d00001e02"
|
||||||
spotifySize640 = "ab67616d0000b273"
|
spotifySize640 = "ab67616d0000b273"
|
||||||
spotifySizeMax = "ab67616d000082c1"
|
spotifySizeMax = "ab67616d000082c1"
|
||||||
)
|
)
|
||||||
@@ -118,21 +119,30 @@ func buildCoverFilename(trackName, artistName, albumName, albumArtist, releaseDa
|
|||||||
return filename + ".cover.jpg"
|
return filename + ".cover.jpg"
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *CoverClient) getMaxResolutionURL(imageURL string) string {
|
func convertSmallToMedium(imageURL string) string {
|
||||||
if strings.Contains(imageURL, spotifySize640) {
|
if strings.Contains(imageURL, spotifySize300) {
|
||||||
return strings.Replace(imageURL, spotifySize640, spotifySizeMax, 1)
|
return strings.Replace(imageURL, spotifySize300, spotifySize640, 1)
|
||||||
}
|
}
|
||||||
return imageURL
|
return imageURL
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *CoverClient) getMaxResolutionURL(imageURL string) string {
|
||||||
|
|
||||||
|
mediumURL := convertSmallToMedium(imageURL)
|
||||||
|
if strings.Contains(mediumURL, spotifySize640) {
|
||||||
|
return strings.Replace(mediumURL, spotifySize640, spotifySizeMax, 1)
|
||||||
|
}
|
||||||
|
return mediumURL
|
||||||
|
}
|
||||||
|
|
||||||
func (c *CoverClient) DownloadCoverToPath(coverURL, outputPath string, embedMaxQualityCover bool) error {
|
func (c *CoverClient) DownloadCoverToPath(coverURL, outputPath string, embedMaxQualityCover bool) error {
|
||||||
if coverURL == "" {
|
if coverURL == "" {
|
||||||
return fmt.Errorf("cover URL is required")
|
return fmt.Errorf("cover URL is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadURL := coverURL
|
downloadURL := convertSmallToMedium(coverURL)
|
||||||
if embedMaxQualityCover {
|
if embedMaxQualityCover {
|
||||||
downloadURL = c.getMaxResolutionURL(coverURL)
|
downloadURL = c.getMaxResolutionURL(downloadURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := c.httpClient.Get(downloadURL)
|
resp, err := c.httpClient.Get(downloadURL)
|
||||||
|
|||||||
@@ -1,208 +0,0 @@
|
|||||||
package backend
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"unicode"
|
|
||||||
)
|
|
||||||
|
|
||||||
var hiraganaToRomaji = map[rune]string{
|
|
||||||
'あ': "a", 'い': "i", 'う': "u", 'え': "e", 'お': "o",
|
|
||||||
'か': "ka", 'き': "ki", 'く': "ku", 'け': "ke", 'こ': "ko",
|
|
||||||
'さ': "sa", 'し': "shi", 'す': "su", 'せ': "se", 'そ': "so",
|
|
||||||
'た': "ta", 'ち': "chi", 'つ': "tsu", 'て': "te", 'と': "to",
|
|
||||||
'な': "na", 'に': "ni", 'ぬ': "nu", 'ね': "ne", 'の': "no",
|
|
||||||
'は': "ha", 'ひ': "hi", 'ふ': "fu", 'へ': "he", 'ほ': "ho",
|
|
||||||
'ま': "ma", 'み': "mi", 'む': "mu", 'め': "me", 'も': "mo",
|
|
||||||
'や': "ya", 'ゆ': "yu", 'よ': "yo",
|
|
||||||
'ら': "ra", 'り': "ri", 'る': "ru", 'れ': "re", 'ろ': "ro",
|
|
||||||
'わ': "wa", 'を': "wo", 'ん': "n",
|
|
||||||
|
|
||||||
'が': "ga", 'ぎ': "gi", 'ぐ': "gu", 'げ': "ge", 'ご': "go",
|
|
||||||
'ざ': "za", 'じ': "ji", 'ず': "zu", 'ぜ': "ze", 'ぞ': "zo",
|
|
||||||
'だ': "da", 'ぢ': "ji", 'づ': "zu", 'で': "de", 'ど': "do",
|
|
||||||
'ば': "ba", 'び': "bi", 'ぶ': "bu", 'べ': "be", 'ぼ': "bo",
|
|
||||||
|
|
||||||
'ぱ': "pa", 'ぴ': "pi", 'ぷ': "pu", 'ぺ': "pe", 'ぽ': "po",
|
|
||||||
|
|
||||||
'ゃ': "ya", 'ゅ': "yu", 'ょ': "yo",
|
|
||||||
'っ': "",
|
|
||||||
'ぁ': "a", 'ぃ': "i", 'ぅ': "u", 'ぇ': "e", 'ぉ': "o",
|
|
||||||
}
|
|
||||||
|
|
||||||
var katakanaToRomaji = map[rune]string{
|
|
||||||
'ア': "a", 'イ': "i", 'ウ': "u", 'エ': "e", 'オ': "o",
|
|
||||||
'カ': "ka", 'キ': "ki", 'ク': "ku", 'ケ': "ke", 'コ': "ko",
|
|
||||||
'サ': "sa", 'シ': "shi", 'ス': "su", 'セ': "se", 'ソ': "so",
|
|
||||||
'タ': "ta", 'チ': "chi", 'ツ': "tsu", 'テ': "te", 'ト': "to",
|
|
||||||
'ナ': "na", 'ニ': "ni", 'ヌ': "nu", 'ネ': "ne", 'ノ': "no",
|
|
||||||
'ハ': "ha", 'ヒ': "hi", 'フ': "fu", 'ヘ': "he", 'ホ': "ho",
|
|
||||||
'マ': "ma", 'ミ': "mi", 'ム': "mu", 'メ': "me", 'モ': "mo",
|
|
||||||
'ヤ': "ya", 'ユ': "yu", 'ヨ': "yo",
|
|
||||||
'ラ': "ra", 'リ': "ri", 'ル': "ru", 'レ': "re", 'ロ': "ro",
|
|
||||||
'ワ': "wa", 'ヲ': "wo", 'ン': "n",
|
|
||||||
|
|
||||||
'ガ': "ga", 'ギ': "gi", 'グ': "gu", 'ゲ': "ge", 'ゴ': "go",
|
|
||||||
'ザ': "za", 'ジ': "ji", 'ズ': "zu", 'ゼ': "ze", 'ゾ': "zo",
|
|
||||||
'ダ': "da", 'ヂ': "ji", 'ヅ': "zu", 'デ': "de", 'ド': "do",
|
|
||||||
'バ': "ba", 'ビ': "bi", 'ブ': "bu", 'ベ': "be", 'ボ': "bo",
|
|
||||||
|
|
||||||
'パ': "pa", 'ピ': "pi", 'プ': "pu", 'ペ': "pe", 'ポ': "po",
|
|
||||||
|
|
||||||
'ャ': "ya", 'ュ': "yu", 'ョ': "yo",
|
|
||||||
'ッ': "",
|
|
||||||
'ァ': "a", 'ィ': "i", 'ゥ': "u", 'ェ': "e", 'ォ': "o",
|
|
||||||
|
|
||||||
'ー': "",
|
|
||||||
'ヴ': "vu",
|
|
||||||
}
|
|
||||||
|
|
||||||
var combinationHiragana = map[string]string{
|
|
||||||
"きゃ": "kya", "きゅ": "kyu", "きょ": "kyo",
|
|
||||||
"しゃ": "sha", "しゅ": "shu", "しょ": "sho",
|
|
||||||
"ちゃ": "cha", "ちゅ": "chu", "ちょ": "cho",
|
|
||||||
"にゃ": "nya", "にゅ": "nyu", "にょ": "nyo",
|
|
||||||
"ひゃ": "hya", "ひゅ": "hyu", "ひょ": "hyo",
|
|
||||||
"みゃ": "mya", "みゅ": "myu", "みょ": "myo",
|
|
||||||
"りゃ": "rya", "りゅ": "ryu", "りょ": "ryo",
|
|
||||||
"ぎゃ": "gya", "ぎゅ": "gyu", "ぎょ": "gyo",
|
|
||||||
"じゃ": "ja", "じゅ": "ju", "じょ": "jo",
|
|
||||||
"びゃ": "bya", "びゅ": "byu", "びょ": "byo",
|
|
||||||
"ぴゃ": "pya", "ぴゅ": "pyu", "ぴょ": "pyo",
|
|
||||||
}
|
|
||||||
|
|
||||||
var combinationKatakana = map[string]string{
|
|
||||||
"キャ": "kya", "キュ": "kyu", "キョ": "kyo",
|
|
||||||
"シャ": "sha", "シュ": "shu", "ショ": "sho",
|
|
||||||
"チャ": "cha", "チュ": "chu", "チョ": "cho",
|
|
||||||
"ニャ": "nya", "ニュ": "nyu", "ニョ": "nyo",
|
|
||||||
"ヒャ": "hya", "ヒュ": "hyu", "ヒョ": "hyo",
|
|
||||||
"ミャ": "mya", "ミュ": "myu", "ミョ": "myo",
|
|
||||||
"リャ": "rya", "リュ": "ryu", "リョ": "ryo",
|
|
||||||
"ギャ": "gya", "ギュ": "gyu", "ギョ": "gyo",
|
|
||||||
"ジャ": "ja", "ジュ": "ju", "ジョ": "jo",
|
|
||||||
"ビャ": "bya", "ビュ": "byu", "ビョ": "byo",
|
|
||||||
"ピャ": "pya", "ピュ": "pyu", "ピョ": "pyo",
|
|
||||||
|
|
||||||
"ティ": "ti", "ディ": "di", "トゥ": "tu", "ドゥ": "du",
|
|
||||||
"ファ": "fa", "フィ": "fi", "フェ": "fe", "フォ": "fo",
|
|
||||||
"ウィ": "wi", "ウェ": "we", "ウォ": "wo",
|
|
||||||
}
|
|
||||||
|
|
||||||
func ContainsJapanese(s string) bool {
|
|
||||||
for _, r := range s {
|
|
||||||
if isHiragana(r) || isKatakana(r) || isKanji(r) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func isHiragana(r rune) bool {
|
|
||||||
return r >= 0x3040 && r <= 0x309F
|
|
||||||
}
|
|
||||||
|
|
||||||
func isKatakana(r rune) bool {
|
|
||||||
return r >= 0x30A0 && r <= 0x30FF
|
|
||||||
}
|
|
||||||
|
|
||||||
func isKanji(r rune) bool {
|
|
||||||
return (r >= 0x4E00 && r <= 0x9FFF) ||
|
|
||||||
(r >= 0x3400 && r <= 0x4DBF)
|
|
||||||
}
|
|
||||||
|
|
||||||
func JapaneseToRomaji(text string) string {
|
|
||||||
if !ContainsJapanese(text) {
|
|
||||||
return text
|
|
||||||
}
|
|
||||||
|
|
||||||
var result strings.Builder
|
|
||||||
runes := []rune(text)
|
|
||||||
i := 0
|
|
||||||
|
|
||||||
for i < len(runes) {
|
|
||||||
|
|
||||||
if i < len(runes)-1 && (runes[i] == 'っ' || runes[i] == 'ッ') {
|
|
||||||
nextRomaji := ""
|
|
||||||
if romaji, ok := hiraganaToRomaji[runes[i+1]]; ok {
|
|
||||||
nextRomaji = romaji
|
|
||||||
} else if romaji, ok := katakanaToRomaji[runes[i+1]]; ok {
|
|
||||||
nextRomaji = romaji
|
|
||||||
}
|
|
||||||
if len(nextRomaji) > 0 {
|
|
||||||
result.WriteByte(nextRomaji[0])
|
|
||||||
}
|
|
||||||
i++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if i < len(runes)-1 {
|
|
||||||
combo := string(runes[i : i+2])
|
|
||||||
if romaji, ok := combinationHiragana[combo]; ok {
|
|
||||||
result.WriteString(romaji)
|
|
||||||
i += 2
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if romaji, ok := combinationKatakana[combo]; ok {
|
|
||||||
result.WriteString(romaji)
|
|
||||||
i += 2
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
r := runes[i]
|
|
||||||
if romaji, ok := hiraganaToRomaji[r]; ok {
|
|
||||||
result.WriteString(romaji)
|
|
||||||
} else if romaji, ok := katakanaToRomaji[r]; ok {
|
|
||||||
result.WriteString(romaji)
|
|
||||||
} else if isKanji(r) {
|
|
||||||
|
|
||||||
result.WriteRune(r)
|
|
||||||
} else {
|
|
||||||
|
|
||||||
result.WriteRune(r)
|
|
||||||
}
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func BuildSearchQuery(trackName, artistName string) string {
|
|
||||||
|
|
||||||
trackRomaji := JapaneseToRomaji(trackName)
|
|
||||||
artistRomaji := JapaneseToRomaji(artistName)
|
|
||||||
|
|
||||||
trackClean := cleanSearchQuery(trackRomaji)
|
|
||||||
artistClean := cleanSearchQuery(artistRomaji)
|
|
||||||
|
|
||||||
return strings.TrimSpace(artistClean + " " + trackClean)
|
|
||||||
}
|
|
||||||
|
|
||||||
func cleanSearchQuery(s string) string {
|
|
||||||
var result strings.Builder
|
|
||||||
for _, r := range s {
|
|
||||||
if unicode.IsLetter(r) || unicode.IsNumber(r) || unicode.IsSpace(r) {
|
|
||||||
result.WriteRune(r)
|
|
||||||
} else if r == '-' || r == '\'' {
|
|
||||||
result.WriteRune(r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(result.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
func cleanToASCII(s string) string {
|
|
||||||
var result strings.Builder
|
|
||||||
for _, r := range s {
|
|
||||||
|
|
||||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
|
|
||||||
(r >= '0' && r <= '9') || r == ' ' || r == '-' || r == '\'' {
|
|
||||||
result.WriteRune(r)
|
|
||||||
} else if r == ',' || r == '.' {
|
|
||||||
|
|
||||||
result.WriteRune(' ')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cleaned := strings.Join(strings.Fields(result.String()), " ")
|
|
||||||
return strings.TrimSpace(cleaned)
|
|
||||||
}
|
|
||||||
+15
-1
@@ -795,7 +795,14 @@ func FilterAlbum(data map[string]interface{}) map[string]interface{} {
|
|||||||
coverObj := extractCoverImage(getMap(albumData, "coverArt"))
|
coverObj := extractCoverImage(getMap(albumData, "coverArt"))
|
||||||
var cover interface{}
|
var cover interface{}
|
||||||
if coverObj != nil {
|
if coverObj != nil {
|
||||||
cover = getString(coverObj, "medium")
|
|
||||||
|
cover = getString(coverObj, "small")
|
||||||
|
if cover == "" {
|
||||||
|
cover = getString(coverObj, "medium")
|
||||||
|
}
|
||||||
|
if cover == "" {
|
||||||
|
cover = getString(coverObj, "large")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tracks := []map[string]interface{}{}
|
tracks := []map[string]interface{}{}
|
||||||
@@ -1053,7 +1060,14 @@ func FilterPlaylist(data map[string]interface{}) map[string]interface{} {
|
|||||||
}
|
}
|
||||||
coverObj := extractCoverImage(getMap(albumData, "coverArt"))
|
coverObj := extractCoverImage(getMap(albumData, "coverArt"))
|
||||||
if coverObj != nil {
|
if coverObj != nil {
|
||||||
|
|
||||||
trackCover = getString(coverObj, "small")
|
trackCover = getString(coverObj, "small")
|
||||||
|
if trackCover == "" {
|
||||||
|
trackCover = getString(coverObj, "medium")
|
||||||
|
}
|
||||||
|
if trackCover == "" {
|
||||||
|
trackCover = getString(coverObj, "large")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -808,12 +808,12 @@ func (c *SpotifyMetadataClient) formatTrackData(raw *apiTrackResponse) TrackResp
|
|||||||
|
|
||||||
externalURL := fmt.Sprintf("https://open.spotify.com/track/%s", raw.ID)
|
externalURL := fmt.Sprintf("https://open.spotify.com/track/%s", raw.ID)
|
||||||
|
|
||||||
coverURL := raw.Cover.Medium
|
coverURL := raw.Cover.Small
|
||||||
if coverURL == "" {
|
if coverURL == "" {
|
||||||
coverURL = raw.Cover.Large
|
coverURL = raw.Cover.Medium
|
||||||
}
|
}
|
||||||
if coverURL == "" {
|
if coverURL == "" {
|
||||||
coverURL = raw.Cover.Small
|
coverURL = raw.Cover.Large
|
||||||
}
|
}
|
||||||
|
|
||||||
releaseDate := raw.Album.Released
|
releaseDate := raw.Album.Released
|
||||||
@@ -834,7 +834,7 @@ func (c *SpotifyMetadataClient) formatTrackData(raw *apiTrackResponse) TrackResp
|
|||||||
DiscNumber: raw.Disc,
|
DiscNumber: raw.Disc,
|
||||||
TotalDiscs: raw.Discs,
|
TotalDiscs: raw.Discs,
|
||||||
ExternalURL: externalURL,
|
ExternalURL: externalURL,
|
||||||
ISRC: raw.ID,
|
ISRC: "",
|
||||||
Copyright: raw.Copyright,
|
Copyright: raw.Copyright,
|
||||||
Publisher: raw.Album.Label,
|
Publisher: raw.Album.Label,
|
||||||
Plays: raw.Plays,
|
Plays: raw.Plays,
|
||||||
@@ -892,7 +892,7 @@ func (c *SpotifyMetadataClient) formatAlbumData(raw *apiAlbumResponse) (*AlbumRe
|
|||||||
DiscNumber: 1,
|
DiscNumber: 1,
|
||||||
TotalDiscs: 0,
|
TotalDiscs: 0,
|
||||||
ExternalURL: fmt.Sprintf("https://open.spotify.com/track/%s", item.ID),
|
ExternalURL: fmt.Sprintf("https://open.spotify.com/track/%s", item.ID),
|
||||||
ISRC: item.ID,
|
ISRC: "",
|
||||||
AlbumID: raw.ID,
|
AlbumID: raw.ID,
|
||||||
AlbumURL: fmt.Sprintf("https://open.spotify.com/album/%s", raw.ID),
|
AlbumURL: fmt.Sprintf("https://open.spotify.com/album/%s", raw.ID),
|
||||||
ArtistID: artistID,
|
ArtistID: artistID,
|
||||||
@@ -951,7 +951,7 @@ func (c *SpotifyMetadataClient) formatPlaylistData(raw *apiPlaylistResponse) Pla
|
|||||||
DiscNumber: 1,
|
DiscNumber: 1,
|
||||||
TotalDiscs: 0,
|
TotalDiscs: 0,
|
||||||
ExternalURL: fmt.Sprintf("https://open.spotify.com/track/%s", item.ID),
|
ExternalURL: fmt.Sprintf("https://open.spotify.com/track/%s", item.ID),
|
||||||
ISRC: item.ID,
|
ISRC: "",
|
||||||
AlbumID: item.AlbumID,
|
AlbumID: item.AlbumID,
|
||||||
AlbumURL: fmt.Sprintf("https://open.spotify.com/album/%s", item.AlbumID),
|
AlbumURL: fmt.Sprintf("https://open.spotify.com/album/%s", item.AlbumID),
|
||||||
ArtistID: artistID,
|
ArtistID: artistID,
|
||||||
|
|||||||
@@ -25,13 +25,6 @@ type TidalDownloader struct {
|
|||||||
apiURL string
|
apiURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
type TidalSearchResponse struct {
|
|
||||||
Limit int `json:"limit"`
|
|
||||||
Offset int `json:"offset"`
|
|
||||||
TotalNumberOfItems int `json:"totalNumberOfItems"`
|
|
||||||
Items []TidalTrack `json:"items"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type TidalTrack struct {
|
type TidalTrack struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
@@ -181,184 +174,6 @@ func (t *TidalDownloader) GetAccessToken() (string, error) {
|
|||||||
return result.AccessToken, nil
|
return result.AccessToken, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *TidalDownloader) SearchTracks(query string) (*TidalSearchResponse, error) {
|
|
||||||
return t.SearchTracksWithLimit(query, 50)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *TidalDownloader) SearchTracksWithLimit(query string, limit int) (*TidalSearchResponse, error) {
|
|
||||||
token, err := t.GetAccessToken()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to get access token: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
searchBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkudGlkYWwuY29tL3YxL3NlYXJjaC90cmFja3M/cXVlcnk9")
|
|
||||||
searchURL := fmt.Sprintf("%s%s&limit=%d&offset=0&countryCode=US", string(searchBase), url.QueryEscape(query), limit)
|
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", searchURL, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
req.Header.Set("Authorization", "Bearer "+token)
|
|
||||||
|
|
||||||
resp, err := t.client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
|
||||||
return nil, fmt.Errorf("search failed: HTTP %d - %s", resp.StatusCode, string(body))
|
|
||||||
}
|
|
||||||
|
|
||||||
var result TidalSearchResponse
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return &result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *TidalDownloader) SearchTrackByMetadata(trackName, artistName string, expectedDuration int) (*TidalTrack, error) {
|
|
||||||
|
|
||||||
queries := []string{}
|
|
||||||
|
|
||||||
if artistName != "" && trackName != "" {
|
|
||||||
queries = append(queries, artistName+" "+trackName)
|
|
||||||
}
|
|
||||||
|
|
||||||
if trackName != "" {
|
|
||||||
queries = append(queries, trackName)
|
|
||||||
}
|
|
||||||
|
|
||||||
if ContainsJapanese(trackName) || ContainsJapanese(artistName) {
|
|
||||||
|
|
||||||
romajiTrack := JapaneseToRomaji(trackName)
|
|
||||||
romajiArtist := JapaneseToRomaji(artistName)
|
|
||||||
|
|
||||||
cleanRomajiTrack := cleanToASCII(romajiTrack)
|
|
||||||
cleanRomajiArtist := cleanToASCII(romajiArtist)
|
|
||||||
|
|
||||||
if cleanRomajiArtist != "" && cleanRomajiTrack != "" {
|
|
||||||
romajiQuery := cleanRomajiArtist + " " + cleanRomajiTrack
|
|
||||||
if !containsQuery(queries, romajiQuery) {
|
|
||||||
queries = append(queries, romajiQuery)
|
|
||||||
fmt.Printf("Japanese detected, adding romaji query: %s\n", romajiQuery)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if cleanRomajiTrack != "" && cleanRomajiTrack != trackName {
|
|
||||||
if !containsQuery(queries, cleanRomajiTrack) {
|
|
||||||
queries = append(queries, cleanRomajiTrack)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if artistName != "" && cleanRomajiTrack != "" {
|
|
||||||
partialQuery := artistName + " " + cleanRomajiTrack
|
|
||||||
if !containsQuery(queries, partialQuery) {
|
|
||||||
queries = append(queries, partialQuery)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if artistName != "" {
|
|
||||||
artistOnly := cleanToASCII(JapaneseToRomaji(artistName))
|
|
||||||
if artistOnly != "" && !containsQuery(queries, artistOnly) {
|
|
||||||
queries = append(queries, artistOnly)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var allTracks []TidalTrack
|
|
||||||
searchedQueries := make(map[string]bool)
|
|
||||||
|
|
||||||
for _, query := range queries {
|
|
||||||
cleanQuery := strings.TrimSpace(query)
|
|
||||||
if cleanQuery == "" || searchedQueries[cleanQuery] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
searchedQueries[cleanQuery] = true
|
|
||||||
|
|
||||||
fmt.Printf("Searching Tidal for: %s\n", cleanQuery)
|
|
||||||
|
|
||||||
result, err := t.SearchTracksWithLimit(cleanQuery, 100)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("Search error for '%s': %v\n", cleanQuery, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(result.Items) > 0 {
|
|
||||||
fmt.Printf("Found %d results for '%s'\n", len(result.Items), cleanQuery)
|
|
||||||
allTracks = append(allTracks, result.Items...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(allTracks) == 0 {
|
|
||||||
return nil, fmt.Errorf("no tracks found for any search query")
|
|
||||||
}
|
|
||||||
|
|
||||||
var bestMatch *TidalTrack
|
|
||||||
if expectedDuration > 0 {
|
|
||||||
tolerance := 3
|
|
||||||
var durationMatches []*TidalTrack
|
|
||||||
|
|
||||||
for i := range allTracks {
|
|
||||||
track := &allTracks[i]
|
|
||||||
durationDiff := track.Duration - expectedDuration
|
|
||||||
if durationDiff < 0 {
|
|
||||||
durationDiff = -durationDiff
|
|
||||||
}
|
|
||||||
if durationDiff <= tolerance {
|
|
||||||
durationMatches = append(durationMatches, track)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(durationMatches) > 0 {
|
|
||||||
|
|
||||||
bestMatch = durationMatches[0]
|
|
||||||
for _, track := range durationMatches {
|
|
||||||
for _, tag := range track.MediaMetadata.Tags {
|
|
||||||
if tag == "HIRES_LOSSLESS" {
|
|
||||||
bestMatch = track
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fmt.Printf("Found via duration match: %s - %s (%s)\n",
|
|
||||||
bestMatch.Artist.Name, bestMatch.Title, bestMatch.AudioQuality)
|
|
||||||
return bestMatch, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
bestMatch = &allTracks[0]
|
|
||||||
for i := range allTracks {
|
|
||||||
track := &allTracks[i]
|
|
||||||
for _, tag := range track.MediaMetadata.Tags {
|
|
||||||
if tag == "HIRES_LOSSLESS" {
|
|
||||||
bestMatch = track
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if bestMatch != &allTracks[0] {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("Found via search (no ISRC provided): %s - %s (ISRC: %s, Quality: %s)\n",
|
|
||||||
bestMatch.Artist.Name, bestMatch.Title, bestMatch.ISRC, bestMatch.AudioQuality)
|
|
||||||
|
|
||||||
return bestMatch, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func containsQuery(queries []string, query string) bool {
|
|
||||||
for _, q := range queries {
|
|
||||||
if q == query {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *TidalDownloader) GetTidalURLFromSpotify(spotifyTrackID string) (string, error) {
|
func (t *TidalDownloader) GetTidalURLFromSpotify(spotifyTrackID string) (string, error) {
|
||||||
|
|
||||||
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
|
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ function App() {
|
|||||||
const [showUnsavedChangesDialog, setShowUnsavedChangesDialog] = useState(false);
|
const [showUnsavedChangesDialog, setShowUnsavedChangesDialog] = useState(false);
|
||||||
const [resetSettingsFn, setResetSettingsFn] = useState<(() => void) | null>(null);
|
const [resetSettingsFn, setResetSettingsFn] = useState<(() => void) | null>(null);
|
||||||
const ITEMS_PER_PAGE = 50;
|
const ITEMS_PER_PAGE = 50;
|
||||||
const CURRENT_VERSION = "7.0.2";
|
const CURRENT_VERSION = "7.0.3";
|
||||||
const download = useDownload();
|
const download = useDownload();
|
||||||
const metadata = useMetadata();
|
const metadata = useMetadata();
|
||||||
const lyrics = useLyrics();
|
const lyrics = useLyrics();
|
||||||
|
|||||||
@@ -95,12 +95,12 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
|||||||
<div className="mt-auto flex flex-col gap-2">
|
<div className="mt-auto flex flex-col gap-2">
|
||||||
<Tooltip delayDuration={0}>
|
<Tooltip delayDuration={0}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button variant="ghost" size="icon" className="h-10 w-10 hover:bg-primary/10 hover:text-primary" onClick={() => openExternal("https://github.com/afkarxyz/SpotiFLAC/issues/new?labels=bug&body=%23%23%23%20Problem%0AExplain%20the%20issue%20briefly.%0A%0A%23%23%23%20Type%0ATrack%20/%20Album%20/%20Playlist%20/%20Artist%0A%0A%23%23%23%20Spotify%20URL%0APaste%20the%20link%20here.%0A%0A%23%23%23%20OS%0AWindows%20/%20Linux%20/%20macOS")}>
|
<Button variant="ghost" size="icon" className="h-10 w-10 hover:bg-primary/10 hover:text-primary" onClick={() => openExternal("https://github.com/afkarxyz/SpotiFLAC/issues/new?title=%5BBug%20Report%5D%20/%20%5BFeature%20Request%5D&body=%3C%21--%20WARNING%3A%20Issues%20that%20do%20not%20follow%20this%20template%20will%20be%20closed%20without%20review.%20Fill%20out%20the%20relevant%20section%20and%20delete%20the%20other.%20--%3E%0A%0A%23%23%23%20%5BBug%20Report%5D%0A%0A%23%23%23%23%20Problem%0A%3E%20Type%20here%0A%0A%23%23%23%23%20Type%0ATrack%20/%20Album%20/%20Playlist%20/%20Artist%0A%0A%23%23%23%23%20Spotify%20URL%0A%3E%20Type%20here%0A%0A%23%23%23%23%20Version%0ASpotiFLAC%20v%0A%0A%23%23%23%23%20OS%0AWindows%20/%20Linux%20/%20macOS%0A%0A%23%23%23%23%20Additional%20Context%0A%3E%20Type%20here%20or%20send%20screenshot%0A%0A---%0A%0A%23%23%23%20%5BFeature%20Request%5D%0A%0A%23%23%23%23%20Description%0A%3E%20Type%20here%0A%0A%23%23%23%23%20Use%20Case%0A%3E%20Type%20here%0A%0A%23%23%23%23%20Additional%20Context%0A%3E%20Type%20here%20or%20send%20screenshot")}>
|
||||||
<GithubIcon size={20}/>
|
<GithubIcon size={20}/>
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="right">
|
<TooltipContent side="right">
|
||||||
<p>Report Bug</p>
|
<p>Report Bug or Feature Request</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip delayDuration={0}>
|
<Tooltip delayDuration={0}>
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@
|
|||||||
},
|
},
|
||||||
"info": {
|
"info": {
|
||||||
"productName": "SpotiFLAC",
|
"productName": "SpotiFLAC",
|
||||||
"productVersion": "7.0.2",
|
"productVersion": "7.0.3",
|
||||||
"copyright": "© 2026 afkarxyz",
|
"copyright": "© 2026 afkarxyz",
|
||||||
"comments": "Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account required."
|
"comments": "Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account required."
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user