From e79622751d481c7fe7c2bfb222c7f86e652c8bd0 Mon Sep 17 00:00:00 2001 From: afkarxyz Date: Mon, 13 Apr 2026 21:17:07 +0700 Subject: [PATCH] .clickable variables --- backend/spotify_metadata.go | 117 ++++++++++++++++++++------ frontend/src/App.tsx | 7 +- frontend/src/components/AlbumInfo.tsx | 59 +++++++++++-- frontend/src/components/TrackInfo.tsx | 36 +++++++- frontend/src/components/TrackList.tsx | 38 ++++----- frontend/src/hooks/useMetadata.ts | 26 +++++- frontend/src/lib/artist-links.ts | 45 ++++++++++ 7 files changed, 266 insertions(+), 62 deletions(-) create mode 100644 frontend/src/lib/artist-links.ts diff --git a/backend/spotify_metadata.go b/backend/spotify_metadata.go index 3d28633..4ab8a90 100644 --- a/backend/spotify_metadata.go +++ b/backend/spotify_metadata.go @@ -33,24 +33,29 @@ func NewSpotifyMetadataClient() *SpotifyMetadataClient { } type TrackMetadata struct { - SpotifyID string `json:"spotify_id,omitempty"` - Artists string `json:"artists"` - Name string `json:"name"` - AlbumName string `json:"album_name"` - AlbumArtist string `json:"album_artist,omitempty"` - DurationMS int `json:"duration_ms"` - Images string `json:"images"` - ReleaseDate string `json:"release_date"` - TrackNumber int `json:"track_number"` - TotalTracks int `json:"total_tracks,omitempty"` - DiscNumber int `json:"disc_number,omitempty"` - TotalDiscs int `json:"total_discs,omitempty"` - ExternalURL string `json:"external_urls"` - Copyright string `json:"copyright,omitempty"` - Publisher string `json:"publisher,omitempty"` - Plays string `json:"plays,omitempty"` - PreviewURL string `json:"preview_url,omitempty"` - IsExplicit bool `json:"is_explicit,omitempty"` + SpotifyID string `json:"spotify_id,omitempty"` + Artists string `json:"artists"` + Name string `json:"name"` + AlbumName string `json:"album_name"` + AlbumArtist string `json:"album_artist,omitempty"` + DurationMS int `json:"duration_ms"` + Images string `json:"images"` + ReleaseDate string `json:"release_date"` + TrackNumber int `json:"track_number"` + TotalTracks int `json:"total_tracks,omitempty"` + DiscNumber int `json:"disc_number,omitempty"` + TotalDiscs int `json:"total_discs,omitempty"` + 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"` + Publisher string `json:"publisher,omitempty"` + Plays string `json:"plays,omitempty"` + PreviewURL string `json:"preview_url,omitempty"` + IsExplicit bool `json:"is_explicit,omitempty"` } type ArtistSimple struct { @@ -179,15 +184,16 @@ type spotifyURI struct { } type apiTrackResponse struct { - ID string `json:"id"` - Name string `json:"name"` - Artists string `json:"artists"` - Duration string `json:"duration"` - Track int `json:"track"` - Disc int `json:"disc"` - Discs int `json:"discs"` - Copyright string `json:"copyright"` - Plays string `json:"plays"` + ID string `json:"id"` + Name string `json:"name"` + Artists string `json:"artists"` + ArtistIds []string `json:"artistIds,omitempty"` + Duration string `json:"duration"` + Track int `json:"track"` + Disc int `json:"disc"` + Discs int `json:"discs"` + Copyright string `json:"copyright"` + Plays string `json:"plays"` Album struct { ID string `json:"id"` Name string `json:"name"` @@ -895,6 +901,34 @@ func (c *SpotifyMetadataClient) formatTrackData(raw *apiTrackResponse) TrackResp durationMS := parseDuration(raw.Duration) 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 if coverURL == "" { @@ -922,6 +956,11 @@ func (c *SpotifyMetadataClient) formatTrackData(raw *apiTrackResponse) TrackResp DiscNumber: raw.Disc, TotalDiscs: raw.Discs, ExternalURL: externalURL, + AlbumID: albumID, + AlbumURL: albumURL, + ArtistID: artistID, + ArtistURL: artistURL, + ArtistsData: artistsData, Copyright: raw.Copyright, Publisher: raw.Album.Label, Plays: raw.Plays, @@ -935,6 +974,18 @@ func (c *SpotifyMetadataClient) formatTrackData(raw *apiTrackResponse) TrackResp func (c *SpotifyMetadataClient) formatAlbumData(raw *apiAlbumResponse, callback MetadataCallback) (*AlbumResponsePayload, error) { 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{ TotalTracks: raw.Count, @@ -1321,6 +1372,18 @@ func parseArtistIDsFromString(artists string) []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) { if query == "" { return nil, errors.New("search query cannot be empty") diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d8f51e1..be255d0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -414,7 +414,12 @@ function App() { if ("track" in metadata.metadata) { const { track } = metadata.metadata; const trackId = track.spotify_id || ""; - return ( 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 ( 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) { const { album_info, track_list } = metadata.metadata; diff --git a/frontend/src/components/AlbumInfo.tsx b/frontend/src/components/AlbumInfo.tsx index a71e280..11d9196 100644 --- a/frontend/src/components/AlbumInfo.tsx +++ b/frontend/src/components/AlbumInfo.tsx @@ -12,6 +12,7 @@ import { useState } from "react"; import { toastWithSound as toast } from "@/lib/toast-with-sound"; import { joinPath, sanitizePath } from "@/lib/utils"; import { parseTemplate, type TemplateData } from "@/lib/settings"; +import { buildClickableArtists, splitArtistNames } from "@/lib/artist-links"; import type { TrackMetadata, TrackAvailability } from "@/types/api"; interface AlbumInfoProps { 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) { const settings = getSettings(); + const albumArtistNames = splitArtistNames(albumInfo.artists); + const artistSeparator = albumInfo.artists.includes(";") ? "; " : ", "; + const clickableAlbumArtists = (() => { + const artistsByName = new Map(); + 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 handleDownloadAlbumCover = async () => { if (!albumInfo.images) @@ -162,13 +204,18 @@ export function AlbumInfo({ albumInfo, trackList, searchQuery, sortBy, selectedT

Album

{albumInfo.name}

- {onArtistClick && albumInfo.artist_id && albumInfo.artist_url ? ( onArtistClick({ - id: albumInfo.artist_id!, - name: albumInfo.artists, - external_urls: albumInfo.artist_url!, + + {clickableAlbumArtists.length > 0 ? clickableAlbumArtists.map((artist, index) => ( + {onArtistClick && artist.external_urls ? ( onArtistClick({ + id: artist.id, + name: artist.name, + external_urls: artist.external_urls, })}> - {albumInfo.artists} - ) : ({albumInfo.artists})} + {artist.name} + ) : (artist.name)} + {index < clickableAlbumArtists.length - 1 && artistSeparator} + )) : albumInfo.artists} + {albumInfo.release_date} diff --git a/frontend/src/components/TrackInfo.tsx b/frontend/src/components/TrackInfo.tsx index dd17676..cc32823 100644 --- a/frontend/src/components/TrackInfo.tsx +++ b/frontend/src/components/TrackInfo.tsx @@ -6,6 +6,7 @@ import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/toolti import type { TrackMetadata, TrackAvailability } from "@/types/api"; import { usePreview } from "@/hooks/usePreview"; import { AvailabilityLinks, hasAvailabilityLinks } from "./AvailabilityLinks"; +import { buildClickableArtists } from "@/lib/artist-links"; interface TrackInfoProps { track: TrackMetadata & { album_name: string; @@ -31,10 +32,22 @@ interface TrackInfoProps { 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; onOpenFolder: () => void; + onAlbumClick?: (album: { + id: string; + name: string; + external_urls: string; + }) => void; + onArtistClick?: (artist: { + id: string; + name: string; + external_urls: string; + }) => 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 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 minutes = Math.floor(ms / 60000); const seconds = Math.floor((ms % 60000) / 1000); @@ -69,13 +82,30 @@ export function TrackInfo({ track, isDownloading, downloadingTrack, isDownloaded {track.is_explicit && (E)} {isSkipped ? () : isDownloaded ? () : isFailed ? () : null}
-

{track.artists}

+

+ {clickableArtists.length > 0 ? clickableArtists.map((artist, index) => ( + {onArtistClick ? ( onArtistClick({ + id: artist.id, + name: artist.name, + external_urls: artist.external_urls, + })}> + {artist.name} + ) : (artist.name)} + {index < clickableArtists.length - 1 && ", "} + )) : track.artists} +

Album

-

{track.album_name}

+

{hasAlbumClick ? ( onAlbumClick?.({ + id: track.album_id!, + name: track.album_name, + external_urls: track.album_url!, + })}> + {track.album_name} + ) : (track.album_name)}

{track.plays && (

Total Plays

diff --git a/frontend/src/components/TrackList.tsx b/frontend/src/components/TrackList.tsx index 02fed48..e0b1175 100644 --- a/frontend/src/components/TrackList.tsx +++ b/frontend/src/components/TrackList.tsx @@ -7,6 +7,7 @@ import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, Pagi import type { TrackMetadata, TrackAvailability } from "@/types/api"; import { usePreview } from "@/hooks/usePreview"; import { AvailabilityLinks, hasAvailabilityLinks } from "./AvailabilityLinks"; +import { buildClickableArtists } from "@/lib/artist-links"; interface TrackListProps { tracks: TrackMetadata[]; searchQuery: string; @@ -249,29 +250,22 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa {track.spotify_id && skippedTracks.has(track.spotify_id) ? () : track.spotify_id && downloadedTracks.has(track.spotify_id) ? () : track.spotify_id && failedTracks.has(track.spotify_id) ? () : null}
- {track.artists_data && track.artists_data.length > 0 ? ((() => { - const artistNames = track.artists.split(", ").map(name => name.trim()); - return artistNames.map((name, i) => { - const artistData = track.artists_data![i]; - const hasArtistData = artistData && artistData.id && artistData.external_urls; - return ( - {onArtistClick && hasArtistData ? ( onArtistClick({ - id: artistData.id, - name: name, - external_urls: artistData.external_urls, + {(() => { + const clickableArtists = buildClickableArtists(track.artists, track.artists_data, track.artist_id, track.artist_url); + if (clickableArtists.length === 0) { + return track.artists; + } + return clickableArtists.map((artist, i) => ( + {onArtistClick ? ( onArtistClick({ + id: artist.id, + name: artist.name, + external_urls: artist.external_urls, })}> - {name} - ) : (name)} - {i < artistNames.length - 1 && ", "} - ); - }); - })()) : onArtistClick && track.artist_id && track.artist_url ? ( onArtistClick({ - id: track.artist_id!, - name: track.artists, - external_urls: track.artist_url!, - })}> - {track.artists} - ) : (track.artists)} + {artist.name} + ) : (artist.name)} + {i < clickableArtists.length - 1 && ", "} + )); + })()}
diff --git a/frontend/src/hooks/useMetadata.ts b/frontend/src/hooks/useMetadata.ts index 80700e7..aab845d 100644 --- a/frontend/src/hooks/useMetadata.ts +++ b/frontend/src/hooks/useMetadata.ts @@ -3,7 +3,7 @@ import { getSettings } from "@/lib/settings"; import { fetchSpotifyMetadata } from "@/lib/api"; import { toastWithSound as toast } from "@/lib/toast-with-sound"; 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 type { SpotifyMetadataResponse } from "@/types/api"; export function useMetadata() { @@ -20,6 +20,19 @@ export function useMetadata() { external_urls: string; } | null>(null); const [pendingArtistName, setPendingArtistName] = useState(null); + const resolveArtistUrlBySearch = async (artistName: string): Promise => { + 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(() => { if (loading) { fetchedCount.current = 0; @@ -262,10 +275,17 @@ export function useMetadata() { external_urls: string; }) => { 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); await fetchMetadataDirectly(artistUrl); - return artistUrl; + return resolvedArtistUrl; }; const handleConfirmAlbumFetch = async () => { if (!selectedAlbum) diff --git a/frontend/src/lib/artist-links.ts b/frontend/src/lib/artist-links.ts new file mode 100644 index 0000000..46ffb61 --- /dev/null +++ b/frontend/src/lib/artist-links.ts @@ -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: "", + }; + }); +}