From 36fb34dc630ebef3b82a9415c658c6c2a0c8f92f Mon Sep 17 00:00:00 2001 From: afkarxyz Date: Sun, 11 Jan 2026 22:41:29 +0700 Subject: [PATCH] v7.0.3 --- app.go | 6 + backend/cover.go | 20 ++- backend/romaji.go | 208 ---------------------------- backend/spotfetch.go | 16 ++- backend/spotify_metadata.go | 12 +- backend/tidal.go | 185 ------------------------- frontend/src/App.tsx | 2 +- frontend/src/components/Sidebar.tsx | 4 +- wails.json | 2 +- 9 files changed, 46 insertions(+), 409 deletions(-) delete mode 100644 backend/romaji.go diff --git a/app.go b/app.go index 86cddbf..5624377 100644 --- a/app.go +++ b/app.go @@ -336,6 +336,12 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) { } deezerISRC := req.ISRC + if deezerISRC != "" { + isrcValid := len(deezerISRC) == 12 && strings.Contains(deezerISRC, "-") + if !isrcValid { + deezerISRC = "" + } + } if deezerISRC == "" && req.SpotifyID != "" { songlinkClient := backend.NewSongLinkClient() diff --git a/backend/cover.go b/backend/cover.go index 73e8bae..5e31d3f 100644 --- a/backend/cover.go +++ b/backend/cover.go @@ -12,6 +12,7 @@ import ( ) const ( + spotifySize300 = "ab67616d00001e02" spotifySize640 = "ab67616d0000b273" spotifySizeMax = "ab67616d000082c1" ) @@ -118,21 +119,30 @@ func buildCoverFilename(trackName, artistName, albumName, albumArtist, releaseDa return filename + ".cover.jpg" } -func (c *CoverClient) getMaxResolutionURL(imageURL string) string { - if strings.Contains(imageURL, spotifySize640) { - return strings.Replace(imageURL, spotifySize640, spotifySizeMax, 1) +func convertSmallToMedium(imageURL string) string { + if strings.Contains(imageURL, spotifySize300) { + return strings.Replace(imageURL, spotifySize300, spotifySize640, 1) } 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 { if coverURL == "" { return fmt.Errorf("cover URL is required") } - downloadURL := coverURL + downloadURL := convertSmallToMedium(coverURL) if embedMaxQualityCover { - downloadURL = c.getMaxResolutionURL(coverURL) + downloadURL = c.getMaxResolutionURL(downloadURL) } resp, err := c.httpClient.Get(downloadURL) diff --git a/backend/romaji.go b/backend/romaji.go deleted file mode 100644 index 54f404e..0000000 --- a/backend/romaji.go +++ /dev/null @@ -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) -} diff --git a/backend/spotfetch.go b/backend/spotfetch.go index f55e498..71054fc 100644 --- a/backend/spotfetch.go +++ b/backend/spotfetch.go @@ -795,7 +795,14 @@ func FilterAlbum(data map[string]interface{}) map[string]interface{} { coverObj := extractCoverImage(getMap(albumData, "coverArt")) var cover interface{} 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{}{} @@ -1053,7 +1060,14 @@ func FilterPlaylist(data map[string]interface{}) map[string]interface{} { } coverObj := extractCoverImage(getMap(albumData, "coverArt")) if coverObj != nil { + trackCover = getString(coverObj, "small") + if trackCover == "" { + trackCover = getString(coverObj, "medium") + } + if trackCover == "" { + trackCover = getString(coverObj, "large") + } } } diff --git a/backend/spotify_metadata.go b/backend/spotify_metadata.go index f46706a..7ba48a3 100644 --- a/backend/spotify_metadata.go +++ b/backend/spotify_metadata.go @@ -808,12 +808,12 @@ func (c *SpotifyMetadataClient) formatTrackData(raw *apiTrackResponse) TrackResp externalURL := fmt.Sprintf("https://open.spotify.com/track/%s", raw.ID) - coverURL := raw.Cover.Medium + coverURL := raw.Cover.Small if coverURL == "" { - coverURL = raw.Cover.Large + coverURL = raw.Cover.Medium } if coverURL == "" { - coverURL = raw.Cover.Small + coverURL = raw.Cover.Large } releaseDate := raw.Album.Released @@ -834,7 +834,7 @@ func (c *SpotifyMetadataClient) formatTrackData(raw *apiTrackResponse) TrackResp DiscNumber: raw.Disc, TotalDiscs: raw.Discs, ExternalURL: externalURL, - ISRC: raw.ID, + ISRC: "", Copyright: raw.Copyright, Publisher: raw.Album.Label, Plays: raw.Plays, @@ -892,7 +892,7 @@ func (c *SpotifyMetadataClient) formatAlbumData(raw *apiAlbumResponse) (*AlbumRe DiscNumber: 1, TotalDiscs: 0, ExternalURL: fmt.Sprintf("https://open.spotify.com/track/%s", item.ID), - ISRC: item.ID, + ISRC: "", AlbumID: raw.ID, AlbumURL: fmt.Sprintf("https://open.spotify.com/album/%s", raw.ID), ArtistID: artistID, @@ -951,7 +951,7 @@ func (c *SpotifyMetadataClient) formatPlaylistData(raw *apiPlaylistResponse) Pla DiscNumber: 1, TotalDiscs: 0, ExternalURL: fmt.Sprintf("https://open.spotify.com/track/%s", item.ID), - ISRC: item.ID, + ISRC: "", AlbumID: item.AlbumID, AlbumURL: fmt.Sprintf("https://open.spotify.com/album/%s", item.AlbumID), ArtistID: artistID, diff --git a/backend/tidal.go b/backend/tidal.go index 74c63d5..116ca05 100644 --- a/backend/tidal.go +++ b/backend/tidal.go @@ -25,13 +25,6 @@ type TidalDownloader struct { apiURL string } -type TidalSearchResponse struct { - Limit int `json:"limit"` - Offset int `json:"offset"` - TotalNumberOfItems int `json:"totalNumberOfItems"` - Items []TidalTrack `json:"items"` -} - type TidalTrack struct { ID int64 `json:"id"` Title string `json:"title"` @@ -181,184 +174,6 @@ func (t *TidalDownloader) GetAccessToken() (string, error) { 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) { spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==") diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index da281db..e287430 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -50,7 +50,7 @@ function App() { const [showUnsavedChangesDialog, setShowUnsavedChangesDialog] = useState(false); const [resetSettingsFn, setResetSettingsFn] = useState<(() => void) | null>(null); const ITEMS_PER_PAGE = 50; - const CURRENT_VERSION = "7.0.2"; + const CURRENT_VERSION = "7.0.3"; const download = useDownload(); const metadata = useMetadata(); const lyrics = useLyrics(); diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 1d3c42c..06d932b 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -95,12 +95,12 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
- -

Report Bug

+

Report Bug or Feature Request

diff --git a/wails.json b/wails.json index 3cc8acb..c3395f8 100644 --- a/wails.json +++ b/wails.json @@ -12,7 +12,7 @@ }, "info": { "productName": "SpotiFLAC", - "productVersion": "7.0.2", + "productVersion": "7.0.3", "copyright": "© 2026 afkarxyz", "comments": "Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account required." },