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: , + }, + { + id: "qobuz", + found: qobuzUrl !== "", + url: qobuzUrl, + icon: , + }, + { + id: "amazon", + found: amazonUrl !== "", + url: amazonUrl, + icon: , + }, + ]; +} + +export function hasAvailabilityLinks(availability?: TrackAvailability): boolean { + if (!availability) { + return false; + } + return getAvailabilityLinkEntries(availability).some((entry) => entry.found); +} + +export function AvailabilityLinks({ availability }: { + availability?: TrackAvailability; +}) { + if (!availability) { + return

Check Availability

; + } + + const entries = getAvailabilityLinkEntries(availability); + return ( +
+ {entries.map((entry) => entry.found ? ( + + ) : ( +
+ {entry.icon} + + Not Found + +
+ ))} +
+ ); +} diff --git a/frontend/src/components/TrackInfo.tsx b/frontend/src/components/TrackInfo.tsx index eac6d31..dd17676 100644 --- a/frontend/src/components/TrackInfo.tsx +++ b/frontend/src/components/TrackInfo.tsx @@ -4,8 +4,8 @@ import { Download, FolderOpen, CheckCircle, XCircle, FileText, FileCheck, Globe, import { Spinner } from "@/components/ui/spinner"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; import type { TrackMetadata, TrackAvailability } from "@/types/api"; -import { TidalAvailabilityIcon, QobuzAvailabilityIcon, AmazonAvailabilityIcon } from "./PlatformIcons"; import { usePreview } from "@/hooks/usePreview"; +import { AvailabilityLinks, hasAvailabilityLinks } from "./AvailabilityLinks"; interface TrackInfoProps { track: TrackMetadata & { album_name: string; @@ -135,15 +135,11 @@ export function TrackInfo({ track, isDownloading, downloadingTrack, isDownloaded {track.spotify_id && onCheckAvailability && ( - - {availability ? (
- - - -
) : (

Check Availability

)} + +
)} {isDownloaded && ( diff --git a/frontend/src/components/TrackList.tsx b/frontend/src/components/TrackList.tsx index 3fefc06..02fed48 100644 --- a/frontend/src/components/TrackList.tsx +++ b/frontend/src/components/TrackList.tsx @@ -5,8 +5,8 @@ import { Spinner } from "@/components/ui/spinner"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious, } from "@/components/ui/pagination"; import type { TrackMetadata, TrackAvailability } from "@/types/api"; -import { TidalAvailabilityIcon, QobuzAvailabilityIcon, AmazonAvailabilityIcon } from "./PlatformIcons"; import { usePreview } from "@/hooks/usePreview"; +import { AvailabilityLinks, hasAvailabilityLinks } from "./AvailabilityLinks"; interface TrackListProps { tracks: TrackMetadata[]; searchQuery: string; @@ -172,6 +172,22 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa return plays; return num.toLocaleString(); }; + const getAvailabilityButtonIcon = (spotifyId?: string) => { + if (!spotifyId) { + return ; + } + if (checkingAvailabilityTrack === spotifyId) { + return ; + } + const availability = availabilityMap?.get(spotifyId); + if (!availability) { + return ; + } + if (hasAvailabilityLinks(availability)) { + return ; + } + return ; + }; return (
@@ -323,15 +339,11 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa {track.spotify_id && onCheckAvailability && ( - - {availabilityMap?.has(track.spotify_id) ? (
- - - -
) : (

Check Availability

)} + +
)}
diff --git a/frontend/src/hooks/useAvailability.ts b/frontend/src/hooks/useAvailability.ts index c835705..e195108 100644 --- a/frontend/src/hooks/useAvailability.ts +++ b/frontend/src/hooks/useAvailability.ts @@ -13,9 +13,6 @@ export function useAvailability() { setError("No Spotify ID provided"); return null; } - if (availabilityMap.has(spotifyId)) { - return availabilityMap.get(spotifyId)!; - } setChecking(true); setCheckingTrackId(spotifyId); setError(null); @@ -41,7 +38,7 @@ export function useAvailability() { setChecking(false); setCheckingTrackId(null); } - }, [availabilityMap]); + }, []); const getAvailability = useCallback((spotifyId: string) => { return availabilityMap.get(spotifyId); }, [availabilityMap]);