.refine check availibility
This commit is contained in:
+84
-5
@@ -47,6 +47,16 @@ type songLinkAPIResponse struct {
|
|||||||
} `json:"linksByPlatform"`
|
} `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 {
|
func NewSongLinkClient() *SongLinkClient {
|
||||||
return &SongLinkClient{
|
return &SongLinkClient{
|
||||||
client: &http.Client{
|
client: &http.Client{
|
||||||
@@ -114,7 +124,7 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string) (*TrackAv
|
|||||||
}
|
}
|
||||||
|
|
||||||
if isrc != "" {
|
if isrc != "" {
|
||||||
availability.Qobuz = checkQobuzAvailability(isrc)
|
availability.Qobuz, availability.QobuzURL = checkQobuzAvailability(isrc)
|
||||||
}
|
}
|
||||||
|
|
||||||
if availability.Tidal || availability.Amazon || availability.Deezer || availability.Qobuz {
|
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")
|
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 {
|
var searchResp struct {
|
||||||
Tracks struct {
|
Tracks struct {
|
||||||
Total int `json:"total"`
|
Total int `json:"total"`
|
||||||
|
Items []qobuzAvailabilityTrack `json:"items"`
|
||||||
} `json:"tracks"`
|
} `json:"tracks"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,10 +202,26 @@ func checkQobuzAvailability(isrc string) bool {
|
|||||||
"query": {strings.TrimSpace(isrc)},
|
"query": {strings.TrimSpace(isrc)},
|
||||||
"limit": {"1"},
|
"limit": {"1"},
|
||||||
}, &searchResp); err != nil {
|
}, &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) {
|
func (s *SongLinkClient) GetDeezerURLFromSpotify(spotifyTrackID string) (string, error) {
|
||||||
|
|||||||
@@ -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: <TidalAvailabilityIcon className={`w-4 h-4 shrink-0 ${tidalUrl ? "text-green-500" : "text-red-500"}`}/>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "qobuz",
|
||||||
|
found: qobuzUrl !== "",
|
||||||
|
url: qobuzUrl,
|
||||||
|
icon: <QobuzAvailabilityIcon className={`w-4 h-4 shrink-0 ${qobuzUrl ? "text-green-500" : "text-red-500"}`}/>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "amazon",
|
||||||
|
found: amazonUrl !== "",
|
||||||
|
url: amazonUrl,
|
||||||
|
icon: <AmazonAvailabilityIcon className={`w-4 h-4 shrink-0 ${amazonUrl ? "text-green-500" : "text-red-500"}`}/>,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <p>Check Availability</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = getAvailabilityLinkEntries(availability);
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1.5 w-[260px] max-w-[260px] pointer-events-auto">
|
||||||
|
{entries.map((entry) => entry.found ? (
|
||||||
|
<button
|
||||||
|
key={entry.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => entry.url && openExternal(entry.url)}
|
||||||
|
className="flex items-center gap-2 text-left text-xs hover:underline min-w-0 cursor-pointer"
|
||||||
|
title={entry.url}
|
||||||
|
>
|
||||||
|
{entry.icon}
|
||||||
|
<span className="truncate whitespace-nowrap leading-5 min-w-0">
|
||||||
|
{entry.url}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
key={entry.id}
|
||||||
|
className="flex items-center gap-2 text-left text-xs min-w-0"
|
||||||
|
>
|
||||||
|
{entry.icon}
|
||||||
|
<span className="truncate whitespace-nowrap leading-5 min-w-0 text-red-500">
|
||||||
|
Not Found
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,8 +4,8 @@ import { Download, FolderOpen, CheckCircle, XCircle, FileText, FileCheck, Globe,
|
|||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
|
||||||
import type { TrackMetadata, TrackAvailability } from "@/types/api";
|
import type { TrackMetadata, TrackAvailability } from "@/types/api";
|
||||||
import { TidalAvailabilityIcon, QobuzAvailabilityIcon, AmazonAvailabilityIcon } from "./PlatformIcons";
|
|
||||||
import { usePreview } from "@/hooks/usePreview";
|
import { usePreview } from "@/hooks/usePreview";
|
||||||
|
import { AvailabilityLinks, hasAvailabilityLinks } from "./AvailabilityLinks";
|
||||||
interface TrackInfoProps {
|
interface TrackInfoProps {
|
||||||
track: TrackMetadata & {
|
track: TrackMetadata & {
|
||||||
album_name: string;
|
album_name: string;
|
||||||
@@ -135,15 +135,11 @@ export function TrackInfo({ track, isDownloading, downloadingTrack, isDownloaded
|
|||||||
{track.spotify_id && onCheckAvailability && (<Tooltip>
|
{track.spotify_id && onCheckAvailability && (<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button onClick={() => onCheckAvailability(track.spotify_id!)} variant="outline" size="icon" disabled={checkingAvailability}>
|
<Button onClick={() => onCheckAvailability(track.spotify_id!)} variant="outline" size="icon" disabled={checkingAvailability}>
|
||||||
{checkingAvailability ? (<Spinner />) : availability ? (<CheckCircle className="h-4 w-4 text-green-500"/>) : (<Globe className="h-4 w-4"/>)}
|
{checkingAvailability ? (<Spinner />) : availability ? (hasAvailabilityLinks(availability) ? (<CheckCircle className="h-4 w-4 text-green-500"/>) : (<XCircle className="h-4 w-4 text-red-500"/>)) : (<Globe className="h-4 w-4"/>)}
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent className="pointer-events-auto">
|
||||||
{availability ? (<div className="flex items-center gap-2">
|
<AvailabilityLinks availability={availability}/>
|
||||||
<TidalAvailabilityIcon className={`w-4 h-4 ${availability.tidal ? "text-green-500" : "text-red-500"}`}/>
|
|
||||||
<QobuzAvailabilityIcon className={`w-4 h-4 ${availability.qobuz ? "text-green-500" : "text-red-500"}`}/>
|
|
||||||
<AmazonAvailabilityIcon className={`w-4 h-4 ${availability.amazon ? "text-green-500" : "text-red-500"}`}/>
|
|
||||||
</div>) : (<p>Check Availability</p>)}
|
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>)}
|
</Tooltip>)}
|
||||||
{isDownloaded && (<Tooltip>
|
{isDownloaded && (<Tooltip>
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import { Spinner } from "@/components/ui/spinner";
|
|||||||
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
|
||||||
import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious, } from "@/components/ui/pagination";
|
import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious, } from "@/components/ui/pagination";
|
||||||
import type { TrackMetadata, TrackAvailability } from "@/types/api";
|
import type { TrackMetadata, TrackAvailability } from "@/types/api";
|
||||||
import { TidalAvailabilityIcon, QobuzAvailabilityIcon, AmazonAvailabilityIcon } from "./PlatformIcons";
|
|
||||||
import { usePreview } from "@/hooks/usePreview";
|
import { usePreview } from "@/hooks/usePreview";
|
||||||
|
import { AvailabilityLinks, hasAvailabilityLinks } from "./AvailabilityLinks";
|
||||||
interface TrackListProps {
|
interface TrackListProps {
|
||||||
tracks: TrackMetadata[];
|
tracks: TrackMetadata[];
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
@@ -172,6 +172,22 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa
|
|||||||
return plays;
|
return plays;
|
||||||
return num.toLocaleString();
|
return num.toLocaleString();
|
||||||
};
|
};
|
||||||
|
const getAvailabilityButtonIcon = (spotifyId?: string) => {
|
||||||
|
if (!spotifyId) {
|
||||||
|
return <Globe className="h-4 w-4"/>;
|
||||||
|
}
|
||||||
|
if (checkingAvailabilityTrack === spotifyId) {
|
||||||
|
return <Spinner />;
|
||||||
|
}
|
||||||
|
const availability = availabilityMap?.get(spotifyId);
|
||||||
|
if (!availability) {
|
||||||
|
return <Globe className="h-4 w-4"/>;
|
||||||
|
}
|
||||||
|
if (hasAvailabilityLinks(availability)) {
|
||||||
|
return <CheckCircle className="h-4 w-4 text-green-500"/>;
|
||||||
|
}
|
||||||
|
return <XCircle className="h-4 w-4 text-red-500"/>;
|
||||||
|
};
|
||||||
return (<div className="space-y-4">
|
return (<div className="space-y-4">
|
||||||
<div className="rounded-md border">
|
<div className="rounded-md border">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
@@ -323,15 +339,11 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa
|
|||||||
{track.spotify_id && onCheckAvailability && (<Tooltip>
|
{track.spotify_id && onCheckAvailability && (<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button onClick={() => onCheckAvailability(track.spotify_id!)} size="icon" variant="outline" disabled={checkingAvailabilityTrack === track.spotify_id}>
|
<Button onClick={() => onCheckAvailability(track.spotify_id!)} size="icon" variant="outline" disabled={checkingAvailabilityTrack === track.spotify_id}>
|
||||||
{checkingAvailabilityTrack === track.spotify_id ? (<Spinner />) : availabilityMap?.has(track.spotify_id) ? (<CheckCircle className="h-4 w-4 text-green-500"/>) : (<Globe className="h-4 w-4"/>)}
|
{getAvailabilityButtonIcon(track.spotify_id)}
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent className="pointer-events-auto">
|
||||||
{availabilityMap?.has(track.spotify_id) ? (<div className="flex items-center gap-2">
|
<AvailabilityLinks availability={track.spotify_id ? availabilityMap?.get(track.spotify_id) : undefined}/>
|
||||||
<TidalAvailabilityIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.tidal ? "text-green-500" : "text-red-500"}`}/>
|
|
||||||
<QobuzAvailabilityIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.qobuz ? "text-green-500" : "text-red-500"}`}/>
|
|
||||||
<AmazonAvailabilityIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.amazon ? "text-green-500" : "text-red-500"}`}/>
|
|
||||||
</div>) : (<p>Check Availability</p>)}
|
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>)}
|
</Tooltip>)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,9 +13,6 @@ export function useAvailability() {
|
|||||||
setError("No Spotify ID provided");
|
setError("No Spotify ID provided");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (availabilityMap.has(spotifyId)) {
|
|
||||||
return availabilityMap.get(spotifyId)!;
|
|
||||||
}
|
|
||||||
setChecking(true);
|
setChecking(true);
|
||||||
setCheckingTrackId(spotifyId);
|
setCheckingTrackId(spotifyId);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -41,7 +38,7 @@ export function useAvailability() {
|
|||||||
setChecking(false);
|
setChecking(false);
|
||||||
setCheckingTrackId(null);
|
setCheckingTrackId(null);
|
||||||
}
|
}
|
||||||
}, [availabilityMap]);
|
}, []);
|
||||||
const getAvailability = useCallback((spotifyId: string) => {
|
const getAvailability = useCallback((spotifyId: string) => {
|
||||||
return availabilityMap.get(spotifyId);
|
return availabilityMap.get(spotifyId);
|
||||||
}, [availabilityMap]);
|
}, [availabilityMap]);
|
||||||
|
|||||||
Reference in New Issue
Block a user