.clickable variables
This commit is contained in:
@@ -46,6 +46,11 @@ type TrackMetadata struct {
|
|||||||
DiscNumber int `json:"disc_number,omitempty"`
|
DiscNumber int `json:"disc_number,omitempty"`
|
||||||
TotalDiscs int `json:"total_discs,omitempty"`
|
TotalDiscs int `json:"total_discs,omitempty"`
|
||||||
ExternalURL string `json:"external_urls"`
|
ExternalURL string `json:"external_urls"`
|
||||||
|
AlbumID string `json:"album_id,omitempty"`
|
||||||
|
AlbumURL string `json:"album_url,omitempty"`
|
||||||
|
ArtistID string `json:"artist_id,omitempty"`
|
||||||
|
ArtistURL string `json:"artist_url,omitempty"`
|
||||||
|
ArtistsData []ArtistSimple `json:"artists_data,omitempty"`
|
||||||
Copyright string `json:"copyright,omitempty"`
|
Copyright string `json:"copyright,omitempty"`
|
||||||
Publisher string `json:"publisher,omitempty"`
|
Publisher string `json:"publisher,omitempty"`
|
||||||
Plays string `json:"plays,omitempty"`
|
Plays string `json:"plays,omitempty"`
|
||||||
@@ -182,6 +187,7 @@ type apiTrackResponse struct {
|
|||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Artists string `json:"artists"`
|
Artists string `json:"artists"`
|
||||||
|
ArtistIds []string `json:"artistIds,omitempty"`
|
||||||
Duration string `json:"duration"`
|
Duration string `json:"duration"`
|
||||||
Track int `json:"track"`
|
Track int `json:"track"`
|
||||||
Disc int `json:"disc"`
|
Disc int `json:"disc"`
|
||||||
@@ -895,6 +901,34 @@ func (c *SpotifyMetadataClient) formatTrackData(raw *apiTrackResponse) TrackResp
|
|||||||
durationMS := parseDuration(raw.Duration)
|
durationMS := parseDuration(raw.Duration)
|
||||||
|
|
||||||
externalURL := fmt.Sprintf("https://open.spotify.com/track/%s", raw.ID)
|
externalURL := fmt.Sprintf("https://open.spotify.com/track/%s", raw.ID)
|
||||||
|
albumID := strings.TrimSpace(raw.Album.ID)
|
||||||
|
albumURL := ""
|
||||||
|
if albumID != "" {
|
||||||
|
albumURL = fmt.Sprintf("https://open.spotify.com/album/%s", albumID)
|
||||||
|
}
|
||||||
|
artistID := ""
|
||||||
|
artistURL := ""
|
||||||
|
artistsData := make([]ArtistSimple, 0, len(raw.ArtistIds))
|
||||||
|
for index, id := range raw.ArtistIds {
|
||||||
|
trimmedID := strings.TrimSpace(id)
|
||||||
|
if trimmedID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if artistID == "" {
|
||||||
|
artistID = trimmedID
|
||||||
|
artistURL = fmt.Sprintf("https://open.spotify.com/artist/%s", trimmedID)
|
||||||
|
}
|
||||||
|
artistName := ""
|
||||||
|
artistNames := splitAndCleanArtists(raw.Artists)
|
||||||
|
if index < len(artistNames) {
|
||||||
|
artistName = artistNames[index]
|
||||||
|
}
|
||||||
|
artistsData = append(artistsData, ArtistSimple{
|
||||||
|
ID: trimmedID,
|
||||||
|
Name: artistName,
|
||||||
|
ExternalURL: fmt.Sprintf("https://open.spotify.com/artist/%s", trimmedID),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
coverURL := raw.Cover.Small
|
coverURL := raw.Cover.Small
|
||||||
if coverURL == "" {
|
if coverURL == "" {
|
||||||
@@ -922,6 +956,11 @@ func (c *SpotifyMetadataClient) formatTrackData(raw *apiTrackResponse) TrackResp
|
|||||||
DiscNumber: raw.Disc,
|
DiscNumber: raw.Disc,
|
||||||
TotalDiscs: raw.Discs,
|
TotalDiscs: raw.Discs,
|
||||||
ExternalURL: externalURL,
|
ExternalURL: externalURL,
|
||||||
|
AlbumID: albumID,
|
||||||
|
AlbumURL: albumURL,
|
||||||
|
ArtistID: artistID,
|
||||||
|
ArtistURL: artistURL,
|
||||||
|
ArtistsData: artistsData,
|
||||||
Copyright: raw.Copyright,
|
Copyright: raw.Copyright,
|
||||||
Publisher: raw.Album.Label,
|
Publisher: raw.Album.Label,
|
||||||
Plays: raw.Plays,
|
Plays: raw.Plays,
|
||||||
@@ -935,6 +974,18 @@ func (c *SpotifyMetadataClient) formatTrackData(raw *apiTrackResponse) TrackResp
|
|||||||
|
|
||||||
func (c *SpotifyMetadataClient) formatAlbumData(raw *apiAlbumResponse, callback MetadataCallback) (*AlbumResponsePayload, error) {
|
func (c *SpotifyMetadataClient) formatAlbumData(raw *apiAlbumResponse, callback MetadataCallback) (*AlbumResponsePayload, error) {
|
||||||
var artistID, artistURL string
|
var artistID, artistURL string
|
||||||
|
for _, item := range raw.Tracks {
|
||||||
|
if len(item.ArtistIds) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
candidate := strings.TrimSpace(item.ArtistIds[0])
|
||||||
|
if candidate == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
artistID = candidate
|
||||||
|
artistURL = fmt.Sprintf("https://open.spotify.com/artist/%s", candidate)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
info := AlbumInfoMetadata{
|
info := AlbumInfoMetadata{
|
||||||
TotalTracks: raw.Count,
|
TotalTracks: raw.Count,
|
||||||
@@ -1321,6 +1372,18 @@ func parseArtistIDsFromString(artists string) []string {
|
|||||||
return []string{}
|
return []string{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func splitAndCleanArtists(artists string) []string {
|
||||||
|
raw := regexp.MustCompile(`\s*[;,]\s*`).Split(strings.TrimSpace(artists), -1)
|
||||||
|
parts := make([]string, 0, len(raw))
|
||||||
|
for _, part := range raw {
|
||||||
|
part = strings.TrimSpace(part)
|
||||||
|
if part != "" {
|
||||||
|
parts = append(parts, part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parts
|
||||||
|
}
|
||||||
|
|
||||||
func (c *SpotifyMetadataClient) Search(ctx context.Context, query string, limit int) (*SearchResponse, error) {
|
func (c *SpotifyMetadataClient) Search(ctx context.Context, query string, limit int) (*SearchResponse, error) {
|
||||||
if query == "" {
|
if query == "" {
|
||||||
return nil, errors.New("search query cannot be empty")
|
return nil, errors.New("search query cannot be empty")
|
||||||
|
|||||||
@@ -414,7 +414,12 @@ function App() {
|
|||||||
if ("track" in metadata.metadata) {
|
if ("track" in metadata.metadata) {
|
||||||
const { track } = metadata.metadata;
|
const { track } = metadata.metadata;
|
||||||
const trackId = track.spotify_id || "";
|
const trackId = track.spotify_id || "";
|
||||||
return (<TrackInfo track={track} isDownloading={download.isDownloading} downloadingTrack={download.downloadingTrack} isDownloaded={download.downloadedTracks.has(trackId)} isFailed={download.failedTracks.has(trackId)} isSkipped={download.skippedTracks.has(trackId)} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} downloadedLyrics={lyrics.downloadedLyrics.has(track.spotify_id || "")} failedLyrics={lyrics.failedLyrics.has(track.spotify_id || "")} skippedLyrics={lyrics.skippedLyrics.has(track.spotify_id || "")} checkingAvailability={availability.checkingTrackId === track.spotify_id} availability={availability.availabilityMap.get(track.spotify_id || "")} downloadingCover={cover.downloadingCoverTrack === (track.spotify_id || `${track.name}-${track.artists}`)} downloadedCover={cover.downloadedCovers.has(track.spotify_id || `${track.name}-${track.artists}`)} failedCover={cover.failedCovers.has(track.spotify_id || `${track.name}-${track.artists}`)} skippedCover={cover.skippedCovers.has(track.spotify_id || `${track.name}-${track.artists}`)} onDownload={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, undefined, undefined, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _playlistName, _position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, undefined, undefined, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onOpenFolder={handleOpenFolder} onBack={metadata.resetMetadata}/>);
|
return (<TrackInfo track={track} isDownloading={download.isDownloading} downloadingTrack={download.downloadingTrack} isDownloaded={download.downloadedTracks.has(trackId)} isFailed={download.failedTracks.has(trackId)} isSkipped={download.skippedTracks.has(trackId)} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} downloadedLyrics={lyrics.downloadedLyrics.has(track.spotify_id || "")} failedLyrics={lyrics.failedLyrics.has(track.spotify_id || "")} skippedLyrics={lyrics.skippedLyrics.has(track.spotify_id || "")} checkingAvailability={availability.checkingTrackId === track.spotify_id} availability={availability.availabilityMap.get(track.spotify_id || "")} downloadingCover={cover.downloadingCoverTrack === (track.spotify_id || `${track.name}-${track.artists}`)} downloadedCover={cover.downloadedCovers.has(track.spotify_id || `${track.name}-${track.artists}`)} failedCover={cover.failedCovers.has(track.spotify_id || `${track.name}-${track.artists}`)} skippedCover={cover.skippedCovers.has(track.spotify_id || `${track.name}-${track.artists}`)} onDownload={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, undefined, undefined, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _playlistName, _position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, undefined, undefined, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onOpenFolder={handleOpenFolder} onAlbumClick={metadata.handleAlbumClick} onArtistClick={async (artist) => {
|
||||||
|
const artistUrl = await metadata.handleArtistClick(artist);
|
||||||
|
if (artistUrl) {
|
||||||
|
setSpotifyUrl(artistUrl);
|
||||||
|
}
|
||||||
|
}} onBack={metadata.resetMetadata}/>);
|
||||||
}
|
}
|
||||||
if ("album_info" in metadata.metadata) {
|
if ("album_info" in metadata.metadata) {
|
||||||
const { album_info, track_list } = metadata.metadata;
|
const { album_info, track_list } = metadata.metadata;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { useState } from "react";
|
|||||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||||
import { joinPath, sanitizePath } from "@/lib/utils";
|
import { joinPath, sanitizePath } from "@/lib/utils";
|
||||||
import { parseTemplate, type TemplateData } from "@/lib/settings";
|
import { parseTemplate, type TemplateData } from "@/lib/settings";
|
||||||
|
import { buildClickableArtists, splitArtistNames } from "@/lib/artist-links";
|
||||||
import type { TrackMetadata, TrackAvailability } from "@/types/api";
|
import type { TrackMetadata, TrackAvailability } from "@/types/api";
|
||||||
interface AlbumInfoProps {
|
interface AlbumInfoProps {
|
||||||
albumInfo: {
|
albumInfo: {
|
||||||
@@ -77,6 +78,47 @@ interface AlbumInfoProps {
|
|||||||
}
|
}
|
||||||
export function AlbumInfo({ albumInfo, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onPageChange, onArtistClick, onTrackClick, onBack, }: AlbumInfoProps) {
|
export function AlbumInfo({ albumInfo, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onPageChange, onArtistClick, onTrackClick, onBack, }: AlbumInfoProps) {
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
|
const albumArtistNames = splitArtistNames(albumInfo.artists);
|
||||||
|
const artistSeparator = albumInfo.artists.includes(";") ? "; " : ", ";
|
||||||
|
const clickableAlbumArtists = (() => {
|
||||||
|
const artistsByName = new Map<string, {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
external_urls: string;
|
||||||
|
}>();
|
||||||
|
for (const track of trackList) {
|
||||||
|
const clickableTrackArtists = buildClickableArtists(track.artists, track.artists_data, track.artist_id, track.artist_url);
|
||||||
|
for (const artist of clickableTrackArtists) {
|
||||||
|
const normalizedName = artist.name.trim().toLowerCase();
|
||||||
|
if (!normalizedName || !artist.external_urls || artistsByName.has(normalizedName)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
artistsByName.set(normalizedName, artist);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return albumArtistNames.map((name) => {
|
||||||
|
const normalizedName = name.trim().toLowerCase();
|
||||||
|
const matchedArtist = artistsByName.get(normalizedName);
|
||||||
|
if (matchedArtist) {
|
||||||
|
return {
|
||||||
|
...matchedArtist,
|
||||||
|
name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (albumArtistNames.length === 1 && albumInfo.artist_id && albumInfo.artist_url) {
|
||||||
|
return {
|
||||||
|
id: albumInfo.artist_id,
|
||||||
|
name,
|
||||||
|
external_urls: albumInfo.artist_url,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: "",
|
||||||
|
name,
|
||||||
|
external_urls: "",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
})();
|
||||||
const [downloadingAlbumCover, setDownloadingAlbumCover] = useState(false);
|
const [downloadingAlbumCover, setDownloadingAlbumCover] = useState(false);
|
||||||
const handleDownloadAlbumCover = async () => {
|
const handleDownloadAlbumCover = async () => {
|
||||||
if (!albumInfo.images)
|
if (!albumInfo.images)
|
||||||
@@ -162,13 +204,18 @@ export function AlbumInfo({ albumInfo, trackList, searchQuery, sortBy, selectedT
|
|||||||
<p className="text-sm font-medium">Album</p>
|
<p className="text-sm font-medium">Album</p>
|
||||||
<h2 className="text-4xl font-bold">{albumInfo.name}</h2>
|
<h2 className="text-4xl font-bold">{albumInfo.name}</h2>
|
||||||
<div className="flex items-center gap-2 text-sm">
|
<div className="flex items-center gap-2 text-sm">
|
||||||
{onArtistClick && albumInfo.artist_id && albumInfo.artist_url ? (<span className="font-medium cursor-pointer hover:underline" onClick={() => onArtistClick({
|
<span className="font-medium">
|
||||||
id: albumInfo.artist_id!,
|
{clickableAlbumArtists.length > 0 ? clickableAlbumArtists.map((artist, index) => (<span key={`${artist.id || artist.name}-${index}`}>
|
||||||
name: albumInfo.artists,
|
{onArtistClick && artist.external_urls ? (<span className="cursor-pointer hover:underline" onClick={() => onArtistClick({
|
||||||
external_urls: albumInfo.artist_url!,
|
id: artist.id,
|
||||||
|
name: artist.name,
|
||||||
|
external_urls: artist.external_urls,
|
||||||
})}>
|
})}>
|
||||||
{albumInfo.artists}
|
{artist.name}
|
||||||
</span>) : (<span className="font-medium">{albumInfo.artists}</span>)}
|
</span>) : (artist.name)}
|
||||||
|
{index < clickableAlbumArtists.length - 1 && artistSeparator}
|
||||||
|
</span>)) : albumInfo.artists}
|
||||||
|
</span>
|
||||||
<span>•</span>
|
<span>•</span>
|
||||||
<span>{albumInfo.release_date}</span>
|
<span>{albumInfo.release_date}</span>
|
||||||
<span>•</span>
|
<span>•</span>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/toolti
|
|||||||
import type { TrackMetadata, TrackAvailability } from "@/types/api";
|
import type { TrackMetadata, TrackAvailability } from "@/types/api";
|
||||||
import { usePreview } from "@/hooks/usePreview";
|
import { usePreview } from "@/hooks/usePreview";
|
||||||
import { AvailabilityLinks, hasAvailabilityLinks } from "./AvailabilityLinks";
|
import { AvailabilityLinks, hasAvailabilityLinks } from "./AvailabilityLinks";
|
||||||
|
import { buildClickableArtists } from "@/lib/artist-links";
|
||||||
interface TrackInfoProps {
|
interface TrackInfoProps {
|
||||||
track: TrackMetadata & {
|
track: TrackMetadata & {
|
||||||
album_name: string;
|
album_name: string;
|
||||||
@@ -31,10 +32,22 @@ interface TrackInfoProps {
|
|||||||
onCheckAvailability?: (spotifyId: string) => void;
|
onCheckAvailability?: (spotifyId: string) => void;
|
||||||
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName?: string, playlistName?: string, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
|
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName?: string, playlistName?: string, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
|
||||||
onOpenFolder: () => void;
|
onOpenFolder: () => void;
|
||||||
|
onAlbumClick?: (album: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
external_urls: string;
|
||||||
|
}) => void;
|
||||||
|
onArtistClick?: (artist: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
external_urls: string;
|
||||||
|
}) => void;
|
||||||
onBack?: () => void;
|
onBack?: () => void;
|
||||||
}
|
}
|
||||||
export function TrackInfo({ track, isDownloading, downloadingTrack, isDownloaded, isFailed, isSkipped, downloadingLyricsTrack, downloadedLyrics, failedLyrics, skippedLyrics, checkingAvailability, availability, downloadingCover, downloadedCover, failedCover, skippedCover, onDownload, onDownloadLyrics, onCheckAvailability, onDownloadCover, onOpenFolder, onBack, }: TrackInfoProps) {
|
export function TrackInfo({ track, isDownloading, downloadingTrack, isDownloaded, isFailed, isSkipped, downloadingLyricsTrack, downloadedLyrics, failedLyrics, skippedLyrics, checkingAvailability, availability, downloadingCover, downloadedCover, failedCover, skippedCover, onDownload, onDownloadLyrics, onCheckAvailability, onDownloadCover, onOpenFolder, onAlbumClick, onArtistClick, onBack, }: TrackInfoProps) {
|
||||||
const { playPreview, loadingPreview, playingTrack } = usePreview();
|
const { playPreview, loadingPreview, playingTrack } = usePreview();
|
||||||
|
const hasAlbumClick = !!(onAlbumClick && track.album_id && track.album_url);
|
||||||
|
const clickableArtists = buildClickableArtists(track.artists, track.artists_data, track.artist_id, track.artist_url);
|
||||||
const formatDuration = (ms: number) => {
|
const formatDuration = (ms: number) => {
|
||||||
const minutes = Math.floor(ms / 60000);
|
const minutes = Math.floor(ms / 60000);
|
||||||
const seconds = Math.floor((ms % 60000) / 1000);
|
const seconds = Math.floor((ms % 60000) / 1000);
|
||||||
@@ -69,13 +82,30 @@ export function TrackInfo({ track, isDownloading, downloadingTrack, isDownloaded
|
|||||||
{track.is_explicit && (<span className="inline-flex items-center justify-center bg-red-600 text-white text-[10px] h-4 w-4 rounded shrink-0" title="Explicit">E</span>)}
|
{track.is_explicit && (<span className="inline-flex items-center justify-center bg-red-600 text-white text-[10px] h-4 w-4 rounded shrink-0" title="Explicit">E</span>)}
|
||||||
{isSkipped ? (<FileCheck className="h-6 w-6 text-yellow-500 shrink-0"/>) : isDownloaded ? (<CheckCircle className="h-6 w-6 text-green-500 shrink-0"/>) : isFailed ? (<XCircle className="h-6 w-6 text-red-500 shrink-0"/>) : null}
|
{isSkipped ? (<FileCheck className="h-6 w-6 text-yellow-500 shrink-0"/>) : isDownloaded ? (<CheckCircle className="h-6 w-6 text-green-500 shrink-0"/>) : isFailed ? (<XCircle className="h-6 w-6 text-red-500 shrink-0"/>) : null}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-lg text-muted-foreground">{track.artists}</p>
|
<p className="text-lg text-muted-foreground">
|
||||||
|
{clickableArtists.length > 0 ? clickableArtists.map((artist, index) => (<span key={`${artist.id || artist.name}-${index}`}>
|
||||||
|
{onArtistClick ? (<span className="cursor-pointer hover:underline" onClick={() => onArtistClick({
|
||||||
|
id: artist.id,
|
||||||
|
name: artist.name,
|
||||||
|
external_urls: artist.external_urls,
|
||||||
|
})}>
|
||||||
|
{artist.name}
|
||||||
|
</span>) : (artist.name)}
|
||||||
|
{index < clickableArtists.length - 1 && ", "}
|
||||||
|
</span>)) : track.artists}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-muted-foreground">Album</p>
|
<p className="text-xs text-muted-foreground">Album</p>
|
||||||
<p className="font-medium truncate">{track.album_name}</p>
|
<p className="font-medium truncate">{hasAlbumClick ? (<span className="cursor-pointer hover:underline" onClick={() => onAlbumClick?.({
|
||||||
|
id: track.album_id!,
|
||||||
|
name: track.album_name,
|
||||||
|
external_urls: track.album_url!,
|
||||||
|
})}>
|
||||||
|
{track.album_name}
|
||||||
|
</span>) : (track.album_name)}</p>
|
||||||
</div>
|
</div>
|
||||||
{track.plays && (<div>
|
{track.plays && (<div>
|
||||||
<p className="text-xs text-muted-foreground">Total Plays</p>
|
<p className="text-xs text-muted-foreground">Total Plays</p>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, Pagi
|
|||||||
import type { TrackMetadata, TrackAvailability } from "@/types/api";
|
import type { TrackMetadata, TrackAvailability } from "@/types/api";
|
||||||
import { usePreview } from "@/hooks/usePreview";
|
import { usePreview } from "@/hooks/usePreview";
|
||||||
import { AvailabilityLinks, hasAvailabilityLinks } from "./AvailabilityLinks";
|
import { AvailabilityLinks, hasAvailabilityLinks } from "./AvailabilityLinks";
|
||||||
|
import { buildClickableArtists } from "@/lib/artist-links";
|
||||||
interface TrackListProps {
|
interface TrackListProps {
|
||||||
tracks: TrackMetadata[];
|
tracks: TrackMetadata[];
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
@@ -249,29 +250,22 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa
|
|||||||
{track.spotify_id && skippedTracks.has(track.spotify_id) ? (<FileCheck className="h-4 w-4 text-yellow-500 shrink-0"/>) : track.spotify_id && downloadedTracks.has(track.spotify_id) ? (<CheckCircle className="h-4 w-4 text-green-500 shrink-0"/>) : track.spotify_id && failedTracks.has(track.spotify_id) ? (<XCircle className="h-4 w-4 text-red-500 shrink-0"/>) : null}
|
{track.spotify_id && skippedTracks.has(track.spotify_id) ? (<FileCheck className="h-4 w-4 text-yellow-500 shrink-0"/>) : track.spotify_id && downloadedTracks.has(track.spotify_id) ? (<CheckCircle className="h-4 w-4 text-green-500 shrink-0"/>) : track.spotify_id && failedTracks.has(track.spotify_id) ? (<XCircle className="h-4 w-4 text-red-500 shrink-0"/>) : null}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
{track.artists_data && track.artists_data.length > 0 ? ((() => {
|
{(() => {
|
||||||
const artistNames = track.artists.split(", ").map(name => name.trim());
|
const clickableArtists = buildClickableArtists(track.artists, track.artists_data, track.artist_id, track.artist_url);
|
||||||
return artistNames.map((name, i) => {
|
if (clickableArtists.length === 0) {
|
||||||
const artistData = track.artists_data![i];
|
return track.artists;
|
||||||
const hasArtistData = artistData && artistData.id && artistData.external_urls;
|
}
|
||||||
return (<span key={artistData?.id || i}>
|
return clickableArtists.map((artist, i) => (<span key={`${artist.id || artist.name}-${i}`}>
|
||||||
{onArtistClick && hasArtistData ? (<span className="cursor-pointer hover:underline" onClick={() => onArtistClick({
|
{onArtistClick ? (<span className="cursor-pointer hover:underline" onClick={() => onArtistClick({
|
||||||
id: artistData.id,
|
id: artist.id,
|
||||||
name: name,
|
name: artist.name,
|
||||||
external_urls: artistData.external_urls,
|
external_urls: artist.external_urls,
|
||||||
})}>
|
})}>
|
||||||
{name}
|
{artist.name}
|
||||||
</span>) : (name)}
|
</span>) : (artist.name)}
|
||||||
{i < artistNames.length - 1 && ", "}
|
{i < clickableArtists.length - 1 && ", "}
|
||||||
</span>);
|
</span>));
|
||||||
});
|
})()}
|
||||||
})()) : onArtistClick && track.artist_id && track.artist_url ? (<span className="cursor-pointer hover:underline" onClick={() => onArtistClick({
|
|
||||||
id: track.artist_id!,
|
|
||||||
name: track.artists,
|
|
||||||
external_urls: track.artist_url!,
|
|
||||||
})}>
|
|
||||||
{track.artists}
|
|
||||||
</span>) : (track.artists)}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { getSettings } from "@/lib/settings";
|
|||||||
import { fetchSpotifyMetadata } from "@/lib/api";
|
import { fetchSpotifyMetadata } from "@/lib/api";
|
||||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
import { AddFetchHistory } from "../../wailsjs/go/main/App";
|
import { AddFetchHistory, SearchSpotifyByType } from "../../wailsjs/go/main/App";
|
||||||
import { EventsOff, EventsOn } from "../../wailsjs/runtime/runtime";
|
import { EventsOff, EventsOn } from "../../wailsjs/runtime/runtime";
|
||||||
import type { SpotifyMetadataResponse } from "@/types/api";
|
import type { SpotifyMetadataResponse } from "@/types/api";
|
||||||
export function useMetadata() {
|
export function useMetadata() {
|
||||||
@@ -20,6 +20,19 @@ export function useMetadata() {
|
|||||||
external_urls: string;
|
external_urls: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [pendingArtistName, setPendingArtistName] = useState<string | null>(null);
|
const [pendingArtistName, setPendingArtistName] = useState<string | null>(null);
|
||||||
|
const resolveArtistUrlBySearch = async (artistName: string): Promise<string | null> => {
|
||||||
|
const query = artistName.trim();
|
||||||
|
if (!query) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const results = await SearchSpotifyByType({
|
||||||
|
query,
|
||||||
|
search_type: "artist",
|
||||||
|
limit: 1,
|
||||||
|
offset: 0,
|
||||||
|
});
|
||||||
|
return results[0]?.external_urls || null;
|
||||||
|
};
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (loading) {
|
if (loading) {
|
||||||
fetchedCount.current = 0;
|
fetchedCount.current = 0;
|
||||||
@@ -262,10 +275,17 @@ export function useMetadata() {
|
|||||||
external_urls: string;
|
external_urls: string;
|
||||||
}) => {
|
}) => {
|
||||||
logger.debug(`artist clicked: ${artist.name}`);
|
logger.debug(`artist clicked: ${artist.name}`);
|
||||||
const artistUrl = artist.external_urls.replace(/\/$/, "") + "/discography/all";
|
const resolvedArtistUrl = artist.external_urls.trim() || (await resolveArtistUrlBySearch(artist.name)) || "";
|
||||||
|
if (!resolvedArtistUrl) {
|
||||||
|
toast.error(`Artist not found: ${artist.name}`);
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const artistUrl = resolvedArtistUrl.includes("/discography")
|
||||||
|
? resolvedArtistUrl
|
||||||
|
: resolvedArtistUrl.replace(/\/$/, "") + "/discography/all";
|
||||||
setPendingArtistName(artist.name);
|
setPendingArtistName(artist.name);
|
||||||
await fetchMetadataDirectly(artistUrl);
|
await fetchMetadataDirectly(artistUrl);
|
||||||
return artistUrl;
|
return resolvedArtistUrl;
|
||||||
};
|
};
|
||||||
const handleConfirmAlbumFetch = async () => {
|
const handleConfirmAlbumFetch = async () => {
|
||||||
if (!selectedAlbum)
|
if (!selectedAlbum)
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import type { ArtistSimple } from "@/types/api";
|
||||||
|
|
||||||
|
export interface ClickableArtist {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
external_urls: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function splitArtistNames(value: string): string[] {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const parts = trimmed.split(/\s*[;,]\s*/).map((part) => part.trim()).filter(Boolean);
|
||||||
|
return parts.length > 0 ? parts : [trimmed];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildClickableArtists(artists: string, artistsData?: ArtistSimple[], fallbackArtistId?: string, fallbackArtistUrl?: string): ClickableArtist[] {
|
||||||
|
const names = splitArtistNames(artists);
|
||||||
|
if (names.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return names.map((name, index) => {
|
||||||
|
const artistData = artistsData?.[index];
|
||||||
|
if (artistData && (artistData.id || artistData.external_urls)) {
|
||||||
|
return {
|
||||||
|
id: artistData.id || "",
|
||||||
|
name,
|
||||||
|
external_urls: artistData.external_urls || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (names.length === 1) {
|
||||||
|
return {
|
||||||
|
id: fallbackArtistId || "",
|
||||||
|
name,
|
||||||
|
external_urls: fallbackArtistUrl || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: "",
|
||||||
|
name,
|
||||||
|
external_urls: "",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user