This commit is contained in:
afkarxyz
2026-01-11 22:41:29 +07:00
parent 7f859db173
commit 36fb34dc63
9 changed files with 46 additions and 409 deletions
+6
View File
@@ -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
View File
@@ -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)
-208
View File
@@ -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)
}
+14
View File
@@ -795,8 +795,15 @@ 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, "small")
if cover == "" {
cover = getString(coverObj, "medium") cover = getString(coverObj, "medium")
} }
if cover == "" {
cover = getString(coverObj, "large")
}
}
tracks := []map[string]interface{}{} tracks := []map[string]interface{}{}
tracksData := getMap(albumData, "tracksV2") tracksData := getMap(albumData, "tracksV2")
@@ -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")
}
} }
} }
+6 -6
View File
@@ -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,
-185
View File
@@ -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==")
+1 -1
View File
@@ -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();
+2 -2
View File
@@ -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
View File
@@ -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."
}, },