diff --git a/backend/songlink.go b/backend/songlink.go
index b56a299..8113a21 100644
--- a/backend/songlink.go
+++ b/backend/songlink.go
@@ -47,6 +47,16 @@ type songLinkAPIResponse struct {
} `json:"linksByPlatform"`
}
+type qobuzAvailabilityTrack struct {
+ ID int64 `json:"id"`
+ Album struct {
+ ID string `json:"id"`
+ Title string `json:"title"`
+ URL string `json:"url"`
+ RelativeURL string `json:"relative_url"`
+ } `json:"album"`
+}
+
func NewSongLinkClient() *SongLinkClient {
return &SongLinkClient{
client: &http.Client{
@@ -114,7 +124,7 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string) (*TrackAv
}
if isrc != "" {
- availability.Qobuz = checkQobuzAvailability(isrc)
+ availability.Qobuz, availability.QobuzURL = checkQobuzAvailability(isrc)
}
if availability.Tidal || availability.Amazon || availability.Deezer || availability.Qobuz {
@@ -128,10 +138,63 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string) (*TrackAv
return availability, fmt.Errorf("no platforms found")
}
-func checkQobuzAvailability(isrc string) bool {
+func qobuzNormalizeRelativeURL(rawURL string) string {
+ rawURL = strings.TrimSpace(rawURL)
+ if rawURL == "" {
+ return ""
+ }
+ if strings.HasPrefix(rawURL, "http://") || strings.HasPrefix(rawURL, "https://") {
+ return rawURL
+ }
+ if strings.HasPrefix(rawURL, "/") {
+ return "https://www.qobuz.com" + rawURL
+ }
+ return "https://www.qobuz.com/" + rawURL
+}
+
+func qobuzSlugifySegment(value string) string {
+ value = strings.ToLower(strings.TrimSpace(value))
+ if value == "" {
+ return ""
+ }
+
+ var builder strings.Builder
+ lastDash := false
+ for _, r := range value {
+ switch {
+ case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
+ builder.WriteRune(r)
+ lastDash = false
+ default:
+ if !lastDash {
+ builder.WriteByte('-')
+ lastDash = true
+ }
+ }
+ }
+
+ return strings.Trim(builder.String(), "-")
+}
+
+func qobuzAlbumSlugURL(albumTitle string, albumID string) string {
+ albumID = strings.TrimSpace(albumID)
+ if albumID == "" {
+ return ""
+ }
+
+ slug := qobuzSlugifySegment(albumTitle)
+ if slug == "" {
+ return fmt.Sprintf("https://www.qobuz.com/album/%s", albumID)
+ }
+
+ return fmt.Sprintf("https://www.qobuz.com/album/%s/%s", slug, albumID)
+}
+
+func checkQobuzAvailability(isrc string) (bool, string) {
var searchResp struct {
Tracks struct {
- Total int `json:"total"`
+ Total int `json:"total"`
+ Items []qobuzAvailabilityTrack `json:"items"`
} `json:"tracks"`
}
@@ -139,10 +202,26 @@ func checkQobuzAvailability(isrc string) bool {
"query": {strings.TrimSpace(isrc)},
"limit": {"1"},
}, &searchResp); err != nil {
- return false
+ return false, ""
}
- return searchResp.Tracks.Total > 0
+ if searchResp.Tracks.Total == 0 || len(searchResp.Tracks.Items) == 0 {
+ return false, ""
+ }
+
+ item := searchResp.Tracks.Items[0]
+ qobuzURL := strings.TrimSpace(item.Album.URL)
+ if qobuzURL == "" {
+ qobuzURL = qobuzNormalizeRelativeURL(item.Album.RelativeURL)
+ }
+ if qobuzURL == "" {
+ qobuzURL = qobuzAlbumSlugURL(item.Album.Title, item.Album.ID)
+ }
+ if qobuzURL == "" && item.ID > 0 {
+ qobuzURL = fmt.Sprintf("https://www.qobuz.com/us-en/track/%d", item.ID)
+ }
+
+ return true, qobuzURL
}
func (s *SongLinkClient) GetDeezerURLFromSpotify(spotifyTrackID string) (string, error) {
diff --git a/frontend/src/components/AvailabilityLinks.tsx b/frontend/src/components/AvailabilityLinks.tsx
new file mode 100644
index 0000000..7cfad6d
--- /dev/null
+++ b/frontend/src/components/AvailabilityLinks.tsx
@@ -0,0 +1,83 @@
+import type { ReactNode } from "react";
+import type { TrackAvailability } from "@/types/api";
+import { openExternal } from "@/lib/utils";
+import { AmazonAvailabilityIcon, QobuzAvailabilityIcon, TidalAvailabilityIcon } from "./PlatformIcons";
+
+interface AvailabilityLinkEntry {
+ id: string;
+ found: boolean;
+ url?: string;
+ icon: ReactNode;
+}
+
+function getAvailabilityLinkEntries(availability: TrackAvailability): AvailabilityLinkEntry[] {
+ const tidalUrl = availability.tidal_url?.trim() || "";
+ const qobuzUrl = availability.qobuz_url?.trim() || "";
+ const amazonUrl = availability.amazon_url?.trim() || "";
+
+ return [
+ {
+ id: "tidal",
+ found: tidalUrl !== "",
+ url: tidalUrl,
+ icon:
Check Availability
; + } + + const entries = getAvailabilityLinkEntries(availability); + return ( +Check Availability
)} +Check Availability
)} +