v6.2
This commit is contained in:
+68
-11
@@ -69,19 +69,31 @@ type TrackMetadata struct {
|
|||||||
SpotifyID string `json:"spotify_id,omitempty"`
|
SpotifyID string `json:"spotify_id,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ArtistSimple holds basic artist info for clickable artists
|
||||||
|
type ArtistSimple struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
ExternalURL string `json:"external_urls"`
|
||||||
|
}
|
||||||
|
|
||||||
// AlbumTrackMetadata holds per-track info for album / playlist formatting.
|
// AlbumTrackMetadata holds per-track info for album / playlist formatting.
|
||||||
type AlbumTrackMetadata struct {
|
type AlbumTrackMetadata struct {
|
||||||
Artists string `json:"artists"`
|
Artists string `json:"artists"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
AlbumName string `json:"album_name"`
|
AlbumName string `json:"album_name"`
|
||||||
DurationMS int `json:"duration_ms"`
|
DurationMS int `json:"duration_ms"`
|
||||||
Images string `json:"images"`
|
Images string `json:"images"`
|
||||||
ReleaseDate string `json:"release_date"`
|
ReleaseDate string `json:"release_date"`
|
||||||
TrackNumber int `json:"track_number"`
|
TrackNumber int `json:"track_number"`
|
||||||
ExternalURL string `json:"external_urls"`
|
ExternalURL string `json:"external_urls"`
|
||||||
ISRC string `json:"isrc"`
|
ISRC string `json:"isrc"`
|
||||||
AlbumType string `json:"album_type,omitempty"`
|
AlbumType string `json:"album_type,omitempty"`
|
||||||
SpotifyID string `json:"spotify_id,omitempty"`
|
SpotifyID string `json:"spotify_id,omitempty"`
|
||||||
|
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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type TrackResponse struct {
|
type TrackResponse struct {
|
||||||
@@ -95,6 +107,8 @@ type AlbumInfoMetadata struct {
|
|||||||
Artists string `json:"artists"`
|
Artists string `json:"artists"`
|
||||||
Images string `json:"images"`
|
Images string `json:"images"`
|
||||||
Batch string `json:"batch,omitempty"`
|
Batch string `json:"batch,omitempty"`
|
||||||
|
ArtistID string `json:"artist_id,omitempty"`
|
||||||
|
ArtistURL string `json:"artist_url,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AlbumResponsePayload struct {
|
type AlbumResponsePayload struct {
|
||||||
@@ -474,6 +488,19 @@ func (c *SpotifyMetadataClient) formatPlaylistData(raw *playlistRaw) PlaylistRes
|
|||||||
if item.Track == nil {
|
if item.Track == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
var artistID, artistURL string
|
||||||
|
if len(item.Track.Artists) > 0 {
|
||||||
|
artistID = item.Track.Artists[0].ID
|
||||||
|
artistURL = fmt.Sprintf("https://open.spotify.com/artist/%s", item.Track.Artists[0].ID)
|
||||||
|
}
|
||||||
|
artistsData := make([]ArtistSimple, 0, len(item.Track.Artists))
|
||||||
|
for _, a := range item.Track.Artists {
|
||||||
|
artistsData = append(artistsData, ArtistSimple{
|
||||||
|
ID: a.ID,
|
||||||
|
Name: a.Name,
|
||||||
|
ExternalURL: fmt.Sprintf("https://open.spotify.com/artist/%s", a.ID),
|
||||||
|
})
|
||||||
|
}
|
||||||
tracks = append(tracks, AlbumTrackMetadata{
|
tracks = append(tracks, AlbumTrackMetadata{
|
||||||
Artists: joinArtists(item.Track.Artists),
|
Artists: joinArtists(item.Track.Artists),
|
||||||
Name: item.Track.Name,
|
Name: item.Track.Name,
|
||||||
@@ -485,6 +512,11 @@ func (c *SpotifyMetadataClient) formatPlaylistData(raw *playlistRaw) PlaylistRes
|
|||||||
ExternalURL: item.Track.ExternalURL.Spotify,
|
ExternalURL: item.Track.ExternalURL.Spotify,
|
||||||
ISRC: item.Track.ExternalID.ISRC,
|
ISRC: item.Track.ExternalID.ISRC,
|
||||||
SpotifyID: item.Track.ID,
|
SpotifyID: item.Track.ID,
|
||||||
|
AlbumID: item.Track.Album.ID,
|
||||||
|
AlbumURL: item.Track.Album.ExternalURL.Spotify,
|
||||||
|
ArtistID: artistID,
|
||||||
|
ArtistURL: artistURL,
|
||||||
|
ArtistsData: artistsData,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -496,12 +528,19 @@ func (c *SpotifyMetadataClient) formatPlaylistData(raw *playlistRaw) PlaylistRes
|
|||||||
|
|
||||||
func (c *SpotifyMetadataClient) formatAlbumData(ctx context.Context, raw *albumRaw) (*AlbumResponsePayload, error) {
|
func (c *SpotifyMetadataClient) formatAlbumData(ctx context.Context, raw *albumRaw) (*AlbumResponsePayload, error) {
|
||||||
albumImage := firstImageURL(raw.Data.Images)
|
albumImage := firstImageURL(raw.Data.Images)
|
||||||
|
var artistID, artistURL string
|
||||||
|
if len(raw.Data.Artists) > 0 {
|
||||||
|
artistID = raw.Data.Artists[0].ID
|
||||||
|
artistURL = fmt.Sprintf("https://open.spotify.com/artist/%s", raw.Data.Artists[0].ID)
|
||||||
|
}
|
||||||
info := AlbumInfoMetadata{
|
info := AlbumInfoMetadata{
|
||||||
TotalTracks: raw.Data.TotalTracks,
|
TotalTracks: raw.Data.TotalTracks,
|
||||||
Name: raw.Data.Name,
|
Name: raw.Data.Name,
|
||||||
ReleaseDate: raw.Data.ReleaseDate,
|
ReleaseDate: raw.Data.ReleaseDate,
|
||||||
Artists: joinArtists(raw.Data.Artists),
|
Artists: joinArtists(raw.Data.Artists),
|
||||||
Images: albumImage,
|
Images: albumImage,
|
||||||
|
ArtistID: artistID,
|
||||||
|
ArtistURL: artistURL,
|
||||||
}
|
}
|
||||||
if raw.BatchEnabled {
|
if raw.BatchEnabled {
|
||||||
info.Batch = strconv.Itoa(maxInt(1, raw.BatchCount))
|
info.Batch = strconv.Itoa(maxInt(1, raw.BatchCount))
|
||||||
@@ -576,6 +615,19 @@ func (c *SpotifyMetadataClient) formatArtistDiscographyData(ctx context.Context,
|
|||||||
|
|
||||||
for _, tr := range tracks {
|
for _, tr := range tracks {
|
||||||
isrc := c.fetchTrackISRC(ctx, tr.ID, raw.Token, isrcCache)
|
isrc := c.fetchTrackISRC(ctx, tr.ID, raw.Token, isrcCache)
|
||||||
|
var artistID, artistURL string
|
||||||
|
if len(tr.Artists) > 0 {
|
||||||
|
artistID = tr.Artists[0].ID
|
||||||
|
artistURL = fmt.Sprintf("https://open.spotify.com/artist/%s", tr.Artists[0].ID)
|
||||||
|
}
|
||||||
|
artistsData := make([]ArtistSimple, 0, len(tr.Artists))
|
||||||
|
for _, a := range tr.Artists {
|
||||||
|
artistsData = append(artistsData, ArtistSimple{
|
||||||
|
ID: a.ID,
|
||||||
|
Name: a.Name,
|
||||||
|
ExternalURL: fmt.Sprintf("https://open.spotify.com/artist/%s", a.ID),
|
||||||
|
})
|
||||||
|
}
|
||||||
allTracks = append(allTracks, AlbumTrackMetadata{
|
allTracks = append(allTracks, AlbumTrackMetadata{
|
||||||
Artists: joinArtists(tr.Artists),
|
Artists: joinArtists(tr.Artists),
|
||||||
Name: tr.Name,
|
Name: tr.Name,
|
||||||
@@ -588,6 +640,11 @@ func (c *SpotifyMetadataClient) formatArtistDiscographyData(ctx context.Context,
|
|||||||
ExternalURL: tr.ExternalURL.Spotify,
|
ExternalURL: tr.ExternalURL.Spotify,
|
||||||
ISRC: isrc,
|
ISRC: isrc,
|
||||||
SpotifyID: tr.ID,
|
SpotifyID: tr.ID,
|
||||||
|
AlbumID: alb.ID,
|
||||||
|
AlbumURL: alb.ExternalURL.Spotify,
|
||||||
|
ArtistID: artistID,
|
||||||
|
ArtistURL: artistURL,
|
||||||
|
ArtistsData: artistsData,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+198
-24
@@ -5,12 +5,11 @@ import {
|
|||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Search } from "lucide-react";
|
import { Search, X } from "lucide-react";
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
import { getSettings, applyThemeMode } from "@/lib/settings";
|
import { getSettings, applyThemeMode } from "@/lib/settings";
|
||||||
import { applyTheme } from "@/lib/themes";
|
import { applyTheme } from "@/lib/themes";
|
||||||
@@ -26,11 +25,15 @@ import { AlbumInfo } from "@/components/AlbumInfo";
|
|||||||
import { PlaylistInfo } from "@/components/PlaylistInfo";
|
import { PlaylistInfo } from "@/components/PlaylistInfo";
|
||||||
import { ArtistInfo } from "@/components/ArtistInfo";
|
import { ArtistInfo } from "@/components/ArtistInfo";
|
||||||
import { DownloadProgressToast } from "@/components/DownloadProgressToast";
|
import { DownloadProgressToast } from "@/components/DownloadProgressToast";
|
||||||
|
import type { HistoryItem } from "@/components/FetchHistory";
|
||||||
|
|
||||||
// Hooks
|
// Hooks
|
||||||
import { useDownload } from "@/hooks/useDownload";
|
import { useDownload } from "@/hooks/useDownload";
|
||||||
import { useMetadata } from "@/hooks/useMetadata";
|
import { useMetadata } from "@/hooks/useMetadata";
|
||||||
|
|
||||||
|
const HISTORY_KEY = "spotiflac_fetch_history";
|
||||||
|
const MAX_HISTORY = 5;
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [spotifyUrl, setSpotifyUrl] = useState("");
|
const [spotifyUrl, setSpotifyUrl] = useState("");
|
||||||
const [selectedTracks, setSelectedTracks] = useState<string[]>([]);
|
const [selectedTracks, setSelectedTracks] = useState<string[]>([]);
|
||||||
@@ -38,9 +41,10 @@ function App() {
|
|||||||
const [sortBy, setSortBy] = useState<string>("default");
|
const [sortBy, setSortBy] = useState<string>("default");
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [hasUpdate, setHasUpdate] = useState(false);
|
const [hasUpdate, setHasUpdate] = useState(false);
|
||||||
|
const [fetchHistory, setFetchHistory] = useState<HistoryItem[]>([]);
|
||||||
|
|
||||||
const ITEMS_PER_PAGE = 50;
|
const ITEMS_PER_PAGE = 50;
|
||||||
const CURRENT_VERSION = "6.1";
|
const CURRENT_VERSION = "6.2";
|
||||||
|
|
||||||
const download = useDownload();
|
const download = useDownload();
|
||||||
const metadata = useMetadata();
|
const metadata = useMetadata();
|
||||||
@@ -61,6 +65,7 @@ function App() {
|
|||||||
|
|
||||||
mediaQuery.addEventListener("change", handleChange);
|
mediaQuery.addEventListener("change", handleChange);
|
||||||
checkForUpdates();
|
checkForUpdates();
|
||||||
|
loadHistory();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
mediaQuery.removeEventListener("change", handleChange);
|
mediaQuery.removeEventListener("change", handleChange);
|
||||||
@@ -78,12 +83,13 @@ function App() {
|
|||||||
const checkForUpdates = async () => {
|
const checkForUpdates = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
"https://cdn.jsdelivr.net/gh/afkarxyz/SpotiFLAC@refs/heads/main/version.json"
|
"https://api.github.com/repos/afkarxyz/SpotiFLAC/releases/latest"
|
||||||
);
|
);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
const latestVersion = data.version;
|
// tag_name format: "v6.1" -> extract "6.1"
|
||||||
|
const latestVersion = data.tag_name?.replace(/^v/, "") || "";
|
||||||
|
|
||||||
if (latestVersion > CURRENT_VERSION) {
|
if (latestVersion && latestVersion > CURRENT_VERSION) {
|
||||||
setHasUpdate(true);
|
setHasUpdate(true);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -91,6 +97,55 @@ function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadHistory = () => {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(HISTORY_KEY);
|
||||||
|
if (saved) {
|
||||||
|
setFetchHistory(JSON.parse(saved));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load history:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveHistory = (history: HistoryItem[]) => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(HISTORY_KEY, JSON.stringify(history));
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to save history:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addToHistory = (item: Omit<HistoryItem, "id" | "timestamp">) => {
|
||||||
|
setFetchHistory((prev) => {
|
||||||
|
const filtered = prev.filter((h) => h.url !== item.url);
|
||||||
|
const newItem: HistoryItem = {
|
||||||
|
...item,
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
const updated = [newItem, ...filtered].slice(0, MAX_HISTORY);
|
||||||
|
saveHistory(updated);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeFromHistory = (id: string) => {
|
||||||
|
setFetchHistory((prev) => {
|
||||||
|
const updated = prev.filter((h) => h.id !== id);
|
||||||
|
saveHistory(updated);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleHistorySelect = async (item: HistoryItem) => {
|
||||||
|
setSpotifyUrl(item.url);
|
||||||
|
const updatedUrl = await metadata.handleFetchMetadata(item.url);
|
||||||
|
if (updatedUrl) {
|
||||||
|
setSpotifyUrl(updatedUrl);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleFetchMetadata = async () => {
|
const handleFetchMetadata = async () => {
|
||||||
const updatedUrl = await metadata.handleFetchMetadata(spotifyUrl);
|
const updatedUrl = await metadata.handleFetchMetadata(spotifyUrl);
|
||||||
if (updatedUrl) {
|
if (updatedUrl) {
|
||||||
@@ -98,6 +153,55 @@ function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Add to history when metadata is successfully fetched
|
||||||
|
useEffect(() => {
|
||||||
|
if (!metadata.metadata || !spotifyUrl) return;
|
||||||
|
|
||||||
|
let historyItem: Omit<HistoryItem, "id" | "timestamp"> | null = null;
|
||||||
|
|
||||||
|
if ("track" in metadata.metadata) {
|
||||||
|
const { track } = metadata.metadata;
|
||||||
|
historyItem = {
|
||||||
|
url: spotifyUrl,
|
||||||
|
type: "track",
|
||||||
|
name: track.name,
|
||||||
|
artist: track.artists,
|
||||||
|
image: track.images,
|
||||||
|
};
|
||||||
|
} else if ("album_info" in metadata.metadata) {
|
||||||
|
const { album_info } = metadata.metadata;
|
||||||
|
historyItem = {
|
||||||
|
url: spotifyUrl,
|
||||||
|
type: "album",
|
||||||
|
name: album_info.name,
|
||||||
|
artist: album_info.artists,
|
||||||
|
image: album_info.images,
|
||||||
|
};
|
||||||
|
} else if ("playlist_info" in metadata.metadata) {
|
||||||
|
const { playlist_info } = metadata.metadata;
|
||||||
|
historyItem = {
|
||||||
|
url: spotifyUrl,
|
||||||
|
type: "playlist",
|
||||||
|
name: playlist_info.owner.name,
|
||||||
|
artist: `${playlist_info.tracks.total} tracks • ${playlist_info.owner.display_name}`,
|
||||||
|
image: playlist_info.owner.images || "",
|
||||||
|
};
|
||||||
|
} else if ("artist_info" in metadata.metadata) {
|
||||||
|
const { artist_info } = metadata.metadata;
|
||||||
|
historyItem = {
|
||||||
|
url: spotifyUrl,
|
||||||
|
type: "artist",
|
||||||
|
name: artist_info.name,
|
||||||
|
artist: `${artist_info.total_albums} albums`,
|
||||||
|
image: artist_info.images,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (historyItem) {
|
||||||
|
addToHistory(historyItem);
|
||||||
|
}
|
||||||
|
}, [metadata.metadata]);
|
||||||
|
|
||||||
const handleSearchChange = (value: string) => {
|
const handleSearchChange = (value: string) => {
|
||||||
setSearchQuery(value);
|
setSearchQuery(value);
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
@@ -162,6 +266,7 @@ function App() {
|
|||||||
selectedTracks={selectedTracks}
|
selectedTracks={selectedTracks}
|
||||||
downloadedTracks={download.downloadedTracks}
|
downloadedTracks={download.downloadedTracks}
|
||||||
failedTracks={download.failedTracks}
|
failedTracks={download.failedTracks}
|
||||||
|
skippedTracks={download.skippedTracks}
|
||||||
downloadingTrack={download.downloadingTrack}
|
downloadingTrack={download.downloadingTrack}
|
||||||
isDownloading={download.isDownloading}
|
isDownloading={download.isDownloading}
|
||||||
bulkDownloadType={download.bulkDownloadType}
|
bulkDownloadType={download.bulkDownloadType}
|
||||||
@@ -181,6 +286,18 @@ function App() {
|
|||||||
onStopDownload={download.handleStopDownload}
|
onStopDownload={download.handleStopDownload}
|
||||||
onOpenFolder={handleOpenFolder}
|
onOpenFolder={handleOpenFolder}
|
||||||
onPageChange={setCurrentPage}
|
onPageChange={setCurrentPage}
|
||||||
|
onArtistClick={async (artist) => {
|
||||||
|
const artistUrl = await metadata.handleArtistClick(artist);
|
||||||
|
if (artistUrl) {
|
||||||
|
setSpotifyUrl(artistUrl);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onTrackClick={async (track) => {
|
||||||
|
if (track.external_urls) {
|
||||||
|
setSpotifyUrl(track.external_urls);
|
||||||
|
await metadata.handleFetchMetadata(track.external_urls);
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -196,6 +313,7 @@ function App() {
|
|||||||
selectedTracks={selectedTracks}
|
selectedTracks={selectedTracks}
|
||||||
downloadedTracks={download.downloadedTracks}
|
downloadedTracks={download.downloadedTracks}
|
||||||
failedTracks={download.failedTracks}
|
failedTracks={download.failedTracks}
|
||||||
|
skippedTracks={download.skippedTracks}
|
||||||
downloadingTrack={download.downloadingTrack}
|
downloadingTrack={download.downloadingTrack}
|
||||||
isDownloading={download.isDownloading}
|
isDownloading={download.isDownloading}
|
||||||
bulkDownloadType={download.bulkDownloadType}
|
bulkDownloadType={download.bulkDownloadType}
|
||||||
@@ -219,6 +337,19 @@ function App() {
|
|||||||
onStopDownload={download.handleStopDownload}
|
onStopDownload={download.handleStopDownload}
|
||||||
onOpenFolder={handleOpenFolder}
|
onOpenFolder={handleOpenFolder}
|
||||||
onPageChange={setCurrentPage}
|
onPageChange={setCurrentPage}
|
||||||
|
onAlbumClick={metadata.handleAlbumClick}
|
||||||
|
onArtistClick={async (artist) => {
|
||||||
|
const artistUrl = await metadata.handleArtistClick(artist);
|
||||||
|
if (artistUrl) {
|
||||||
|
setSpotifyUrl(artistUrl);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onTrackClick={async (track) => {
|
||||||
|
if (track.external_urls) {
|
||||||
|
setSpotifyUrl(track.external_urls);
|
||||||
|
await metadata.handleFetchMetadata(track.external_urls);
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -235,6 +366,7 @@ function App() {
|
|||||||
selectedTracks={selectedTracks}
|
selectedTracks={selectedTracks}
|
||||||
downloadedTracks={download.downloadedTracks}
|
downloadedTracks={download.downloadedTracks}
|
||||||
failedTracks={download.failedTracks}
|
failedTracks={download.failedTracks}
|
||||||
|
skippedTracks={download.skippedTracks}
|
||||||
downloadingTrack={download.downloadingTrack}
|
downloadingTrack={download.downloadingTrack}
|
||||||
isDownloading={download.isDownloading}
|
isDownloading={download.isDownloading}
|
||||||
bulkDownloadType={download.bulkDownloadType}
|
bulkDownloadType={download.bulkDownloadType}
|
||||||
@@ -254,7 +386,19 @@ function App() {
|
|||||||
onStopDownload={download.handleStopDownload}
|
onStopDownload={download.handleStopDownload}
|
||||||
onOpenFolder={handleOpenFolder}
|
onOpenFolder={handleOpenFolder}
|
||||||
onAlbumClick={metadata.handleAlbumClick}
|
onAlbumClick={metadata.handleAlbumClick}
|
||||||
|
onArtistClick={async (artist) => {
|
||||||
|
const artistUrl = await metadata.handleArtistClick(artist);
|
||||||
|
if (artistUrl) {
|
||||||
|
setSpotifyUrl(artistUrl);
|
||||||
|
}
|
||||||
|
}}
|
||||||
onPageChange={setCurrentPage}
|
onPageChange={setCurrentPage}
|
||||||
|
onTrackClick={async (track) => {
|
||||||
|
if (track.external_urls) {
|
||||||
|
setSpotifyUrl(track.external_urls);
|
||||||
|
await metadata.handleFetchMetadata(track.external_urls);
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -278,14 +422,27 @@ function App() {
|
|||||||
open={metadata.showTimeoutDialog}
|
open={metadata.showTimeoutDialog}
|
||||||
onOpenChange={metadata.setShowTimeoutDialog}
|
onOpenChange={metadata.setShowTimeoutDialog}
|
||||||
>
|
>
|
||||||
<DialogContent className="sm:max-w-[425px]">
|
<DialogContent className="sm:max-w-[425px] p-6 [&>button]:hidden">
|
||||||
<DialogHeader>
|
<div className="absolute right-4 top-4">
|
||||||
<DialogTitle>Fetch Settings</DialogTitle>
|
<Button
|
||||||
<DialogDescription>
|
variant="ghost"
|
||||||
Set timeout for fetching metadata. Longer timeout is recommended for artists
|
size="icon"
|
||||||
with large discography.
|
className="h-6 w-6 opacity-70 hover:opacity-100"
|
||||||
</DialogDescription>
|
onClick={() => metadata.setShowTimeoutDialog(false)}
|
||||||
</DialogHeader>
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<DialogTitle className="text-sm font-medium">Fetch Artist</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Set timeout for fetching metadata. Longer timeout is recommended for artists
|
||||||
|
with large discography.
|
||||||
|
</DialogDescription>
|
||||||
|
{metadata.pendingArtistName && (
|
||||||
|
<div className="py-2">
|
||||||
|
<p className="font-medium bg-muted/50 rounded-md px-3 py-2">{metadata.pendingArtistName}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="space-y-4 py-4">
|
<div className="space-y-4 py-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="timeout">Timeout (seconds)</Label>
|
<Label htmlFor="timeout">Timeout (seconds)</Label>
|
||||||
@@ -320,23 +477,36 @@ function App() {
|
|||||||
|
|
||||||
{/* Album Fetch Dialog */}
|
{/* Album Fetch Dialog */}
|
||||||
<Dialog open={metadata.showAlbumDialog} onOpenChange={metadata.setShowAlbumDialog}>
|
<Dialog open={metadata.showAlbumDialog} onOpenChange={metadata.setShowAlbumDialog}>
|
||||||
<DialogContent className="sm:max-w-[425px]">
|
<DialogContent className="sm:max-w-[425px] p-6 [&>button]:hidden">
|
||||||
<DialogHeader>
|
<div className="absolute right-4 top-4">
|
||||||
<DialogTitle>Fetch Album</DialogTitle>
|
<Button
|
||||||
<DialogDescription>
|
variant="ghost"
|
||||||
Do you want to fetch metadata for this album?
|
size="icon"
|
||||||
</DialogDescription>
|
className="h-6 w-6 opacity-70 hover:opacity-100"
|
||||||
</DialogHeader>
|
onClick={() => metadata.setShowAlbumDialog(false)}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<DialogTitle className="text-sm font-medium">Fetch Album</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Do you want to fetch metadata for this album?
|
||||||
|
</DialogDescription>
|
||||||
{metadata.selectedAlbum && (
|
{metadata.selectedAlbum && (
|
||||||
<div className="py-4">
|
<div className="py-2">
|
||||||
<p className="font-medium">{metadata.selectedAlbum.name}</p>
|
<p className="font-medium bg-muted/50 rounded-md px-3 py-2">{metadata.selectedAlbum.name}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => metadata.setShowAlbumDialog(false)}>
|
<Button variant="outline" onClick={() => metadata.setShowAlbumDialog(false)}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={metadata.handleConfirmAlbumFetch}>
|
<Button onClick={async () => {
|
||||||
|
const albumUrl = await metadata.handleConfirmAlbumFetch();
|
||||||
|
if (albumUrl) {
|
||||||
|
setSpotifyUrl(albumUrl);
|
||||||
|
}
|
||||||
|
}}>
|
||||||
<Search className="h-4 w-4" />
|
<Search className="h-4 w-4" />
|
||||||
Fetch Album
|
Fetch Album
|
||||||
</Button>
|
</Button>
|
||||||
@@ -349,6 +519,10 @@ function App() {
|
|||||||
loading={metadata.loading}
|
loading={metadata.loading}
|
||||||
onUrlChange={setSpotifyUrl}
|
onUrlChange={setSpotifyUrl}
|
||||||
onFetch={handleFetchMetadata}
|
onFetch={handleFetchMetadata}
|
||||||
|
history={fetchHistory}
|
||||||
|
onHistorySelect={handleHistorySelect}
|
||||||
|
onHistoryRemove={removeFromHistory}
|
||||||
|
hasResult={!!metadata.metadata}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{metadata.metadata && renderMetadata()}
|
{metadata.metadata && renderMetadata()}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ interface AlbumInfoProps {
|
|||||||
images: string;
|
images: string;
|
||||||
release_date: string;
|
release_date: string;
|
||||||
total_tracks: number;
|
total_tracks: number;
|
||||||
|
artist_id?: string;
|
||||||
|
artist_url?: string;
|
||||||
};
|
};
|
||||||
trackList: TrackMetadata[];
|
trackList: TrackMetadata[];
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
@@ -21,6 +23,7 @@ interface AlbumInfoProps {
|
|||||||
selectedTracks: string[];
|
selectedTracks: string[];
|
||||||
downloadedTracks: Set<string>;
|
downloadedTracks: Set<string>;
|
||||||
failedTracks: Set<string>;
|
failedTracks: Set<string>;
|
||||||
|
skippedTracks: Set<string>;
|
||||||
downloadingTrack: string | null;
|
downloadingTrack: string | null;
|
||||||
isDownloading: boolean;
|
isDownloading: boolean;
|
||||||
bulkDownloadType: "all" | "selected" | null;
|
bulkDownloadType: "all" | "selected" | null;
|
||||||
@@ -32,12 +35,14 @@ interface AlbumInfoProps {
|
|||||||
onSortChange: (value: string) => void;
|
onSortChange: (value: string) => void;
|
||||||
onToggleTrack: (isrc: string) => void;
|
onToggleTrack: (isrc: string) => void;
|
||||||
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
|
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
|
||||||
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string) => void;
|
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string) => void;
|
||||||
onDownloadAll: () => void;
|
onDownloadAll: () => void;
|
||||||
onDownloadSelected: () => void;
|
onDownloadSelected: () => void;
|
||||||
onStopDownload: () => void;
|
onStopDownload: () => void;
|
||||||
onOpenFolder: () => void;
|
onOpenFolder: () => void;
|
||||||
onPageChange: (page: number) => void;
|
onPageChange: (page: number) => void;
|
||||||
|
onArtistClick?: (artist: { id: string; name: string; external_urls: string }) => void;
|
||||||
|
onTrackClick?: (track: TrackMetadata) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AlbumInfo({
|
export function AlbumInfo({
|
||||||
@@ -48,6 +53,7 @@ export function AlbumInfo({
|
|||||||
selectedTracks,
|
selectedTracks,
|
||||||
downloadedTracks,
|
downloadedTracks,
|
||||||
failedTracks,
|
failedTracks,
|
||||||
|
skippedTracks,
|
||||||
downloadingTrack,
|
downloadingTrack,
|
||||||
isDownloading,
|
isDownloading,
|
||||||
bulkDownloadType,
|
bulkDownloadType,
|
||||||
@@ -65,6 +71,8 @@ export function AlbumInfo({
|
|||||||
onStopDownload,
|
onStopDownload,
|
||||||
onOpenFolder,
|
onOpenFolder,
|
||||||
onPageChange,
|
onPageChange,
|
||||||
|
onArtistClick,
|
||||||
|
onTrackClick,
|
||||||
}: AlbumInfoProps) {
|
}: AlbumInfoProps) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -83,7 +91,22 @@ export function AlbumInfo({
|
|||||||
<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">
|
||||||
<span className="font-medium">{albumInfo.artists}</span>
|
{onArtistClick && albumInfo.artist_id && albumInfo.artist_url ? (
|
||||||
|
<span
|
||||||
|
className="font-medium cursor-pointer hover:underline"
|
||||||
|
onClick={() =>
|
||||||
|
onArtistClick({
|
||||||
|
id: albumInfo.artist_id!,
|
||||||
|
name: albumInfo.artists,
|
||||||
|
external_urls: albumInfo.artist_url!,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{albumInfo.artists}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="font-medium">{albumInfo.artists}</span>
|
||||||
|
)}
|
||||||
<span>•</span>
|
<span>•</span>
|
||||||
<span>{albumInfo.release_date}</span>
|
<span>{albumInfo.release_date}</span>
|
||||||
<span>•</span>
|
<span>•</span>
|
||||||
@@ -148,16 +171,19 @@ export function AlbumInfo({
|
|||||||
selectedTracks={selectedTracks}
|
selectedTracks={selectedTracks}
|
||||||
downloadedTracks={downloadedTracks}
|
downloadedTracks={downloadedTracks}
|
||||||
failedTracks={failedTracks}
|
failedTracks={failedTracks}
|
||||||
|
skippedTracks={skippedTracks}
|
||||||
downloadingTrack={downloadingTrack}
|
downloadingTrack={downloadingTrack}
|
||||||
isDownloading={isDownloading}
|
isDownloading={isDownloading}
|
||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
itemsPerPage={itemsPerPage}
|
itemsPerPage={itemsPerPage}
|
||||||
showCheckboxes={true}
|
showCheckboxes={true}
|
||||||
hideAlbumColumn={true}
|
hideAlbumColumn={true}
|
||||||
|
folderName={albumInfo.name}
|
||||||
onToggleTrack={onToggleTrack}
|
onToggleTrack={onToggleTrack}
|
||||||
onToggleSelectAll={onToggleSelectAll}
|
onToggleSelectAll={onToggleSelectAll}
|
||||||
onDownloadTrack={onDownloadTrack}
|
onDownloadTrack={onDownloadTrack}
|
||||||
onPageChange={onPageChange}
|
onPageChange={onPageChange}
|
||||||
|
onTrackClick={onTrackClick}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ interface ArtistInfoProps {
|
|||||||
selectedTracks: string[];
|
selectedTracks: string[];
|
||||||
downloadedTracks: Set<string>;
|
downloadedTracks: Set<string>;
|
||||||
failedTracks: Set<string>;
|
failedTracks: Set<string>;
|
||||||
|
skippedTracks: Set<string>;
|
||||||
downloadingTrack: string | null;
|
downloadingTrack: string | null;
|
||||||
isDownloading: boolean;
|
isDownloading: boolean;
|
||||||
bulkDownloadType: "all" | "selected" | null;
|
bulkDownloadType: "all" | "selected" | null;
|
||||||
@@ -39,13 +40,15 @@ interface ArtistInfoProps {
|
|||||||
onSortChange: (value: string) => void;
|
onSortChange: (value: string) => void;
|
||||||
onToggleTrack: (isrc: string) => void;
|
onToggleTrack: (isrc: string) => void;
|
||||||
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
|
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
|
||||||
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string) => void;
|
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string) => void;
|
||||||
onDownloadAll: () => void;
|
onDownloadAll: () => void;
|
||||||
onDownloadSelected: () => void;
|
onDownloadSelected: () => void;
|
||||||
onStopDownload: () => void;
|
onStopDownload: () => void;
|
||||||
onOpenFolder: () => void;
|
onOpenFolder: () => void;
|
||||||
onAlbumClick: (album: { id: string; name: string; external_urls: string }) => void;
|
onAlbumClick: (album: { id: string; name: string; external_urls: string }) => void;
|
||||||
|
onArtistClick: (artist: { id: string; name: string; external_urls: string }) => void;
|
||||||
onPageChange: (page: number) => void;
|
onPageChange: (page: number) => void;
|
||||||
|
onTrackClick?: (track: TrackMetadata) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ArtistInfo({
|
export function ArtistInfo({
|
||||||
@@ -57,6 +60,7 @@ export function ArtistInfo({
|
|||||||
selectedTracks,
|
selectedTracks,
|
||||||
downloadedTracks,
|
downloadedTracks,
|
||||||
failedTracks,
|
failedTracks,
|
||||||
|
skippedTracks,
|
||||||
downloadingTrack,
|
downloadingTrack,
|
||||||
isDownloading,
|
isDownloading,
|
||||||
bulkDownloadType,
|
bulkDownloadType,
|
||||||
@@ -74,7 +78,9 @@ export function ArtistInfo({
|
|||||||
onStopDownload,
|
onStopDownload,
|
||||||
onOpenFolder,
|
onOpenFolder,
|
||||||
onAlbumClick,
|
onAlbumClick,
|
||||||
|
onArtistClick,
|
||||||
onPageChange,
|
onPageChange,
|
||||||
|
onTrackClick,
|
||||||
}: ArtistInfoProps) {
|
}: ArtistInfoProps) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -91,8 +97,12 @@ export function ArtistInfo({
|
|||||||
<div className="flex-1 space-y-2">
|
<div className="flex-1 space-y-2">
|
||||||
<p className="text-sm font-medium">Artist</p>
|
<p className="text-sm font-medium">Artist</p>
|
||||||
<h2 className="text-4xl font-bold">{artistInfo.name}</h2>
|
<h2 className="text-4xl font-bold">{artistInfo.name}</h2>
|
||||||
<div className="flex items-center gap-2 text-sm">
|
<div className="flex items-center gap-2 text-sm flex-wrap">
|
||||||
<span>{artistInfo.followers.toLocaleString()} followers</span>
|
<span>{artistInfo.followers.toLocaleString()} followers</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{albumList.length} albums</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{trackList.length} tracks</span>
|
||||||
{artistInfo.genres.length > 0 && (
|
{artistInfo.genres.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<span>•</span>
|
<span>•</span>
|
||||||
@@ -200,16 +210,22 @@ export function ArtistInfo({
|
|||||||
selectedTracks={selectedTracks}
|
selectedTracks={selectedTracks}
|
||||||
downloadedTracks={downloadedTracks}
|
downloadedTracks={downloadedTracks}
|
||||||
failedTracks={failedTracks}
|
failedTracks={failedTracks}
|
||||||
|
skippedTracks={skippedTracks}
|
||||||
downloadingTrack={downloadingTrack}
|
downloadingTrack={downloadingTrack}
|
||||||
isDownloading={isDownloading}
|
isDownloading={isDownloading}
|
||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
itemsPerPage={itemsPerPage}
|
itemsPerPage={itemsPerPage}
|
||||||
showCheckboxes={true}
|
showCheckboxes={true}
|
||||||
hideAlbumColumn={false}
|
hideAlbumColumn={false}
|
||||||
|
folderName={artistInfo.name}
|
||||||
|
isArtistDiscography={true}
|
||||||
onToggleTrack={onToggleTrack}
|
onToggleTrack={onToggleTrack}
|
||||||
onToggleSelectAll={onToggleSelectAll}
|
onToggleSelectAll={onToggleSelectAll}
|
||||||
onDownloadTrack={onDownloadTrack}
|
onDownloadTrack={onDownloadTrack}
|
||||||
onPageChange={onPageChange}
|
onPageChange={onPageChange}
|
||||||
|
onAlbumClick={onAlbumClick}
|
||||||
|
onArtistClick={onArtistClick}
|
||||||
|
onTrackClick={onTrackClick}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
import { Bug, Trash2, X, Copy, Check } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { logger, type LogEntry } from "@/lib/logger";
|
||||||
|
|
||||||
|
const levelColors: Record<string, string> = {
|
||||||
|
info: "text-blue-500",
|
||||||
|
success: "text-green-500",
|
||||||
|
warning: "text-yellow-500",
|
||||||
|
error: "text-red-500",
|
||||||
|
debug: "text-gray-500",
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatTime(date: Date): string {
|
||||||
|
return date.toLocaleTimeString("en-US", {
|
||||||
|
hour12: false,
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DebugLogger() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = logger.subscribe(() => {
|
||||||
|
setLogs(logger.getLogs());
|
||||||
|
});
|
||||||
|
setLogs(logger.getLogs());
|
||||||
|
return () => {
|
||||||
|
unsubscribe();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (scrollRef.current) {
|
||||||
|
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||||
|
}
|
||||||
|
}, [logs]);
|
||||||
|
|
||||||
|
const handleClear = () => {
|
||||||
|
logger.clear();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
const logText = logs
|
||||||
|
.map((log) => `[${formatTime(log.timestamp)}] [${log.level}] ${log.message}`)
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(logText);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 500);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to copy logs:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 opacity-50 hover:opacity-100"
|
||||||
|
>
|
||||||
|
<Bug className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-[600px] max-h-[80vh] p-6 [&>button]:hidden">
|
||||||
|
<DialogTitle className="text-sm font-medium">Debug Logs</DialogTitle>
|
||||||
|
<div className="absolute right-4 top-4 flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 opacity-70 hover:opacity-100"
|
||||||
|
onClick={handleCopy}
|
||||||
|
disabled={logs.length === 0}
|
||||||
|
>
|
||||||
|
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 opacity-70 hover:opacity-100"
|
||||||
|
onClick={handleClear}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 opacity-70 hover:opacity-100"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
className="h-[400px] overflow-y-auto bg-muted/50 rounded-md p-3 font-mono text-xs"
|
||||||
|
>
|
||||||
|
{logs.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground lowercase">no logs yet...</p>
|
||||||
|
) : (
|
||||||
|
logs.map((log, i) => (
|
||||||
|
<div key={i} className="flex gap-2 py-0.5">
|
||||||
|
<span className="text-muted-foreground shrink-0">
|
||||||
|
[{formatTime(log.timestamp)}]
|
||||||
|
</span>
|
||||||
|
<span className={`shrink-0 w-16 ${levelColors[log.level]}`}>
|
||||||
|
[{log.level}]
|
||||||
|
</span>
|
||||||
|
<span className="break-all">{log.message}</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { X } from "lucide-react";
|
||||||
|
|
||||||
|
export interface HistoryItem {
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
type: "track" | "album" | "playlist" | "artist";
|
||||||
|
name: string;
|
||||||
|
artist: string;
|
||||||
|
image: string;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FetchHistoryProps {
|
||||||
|
history: HistoryItem[];
|
||||||
|
onSelect: (item: HistoryItem) => void;
|
||||||
|
onRemove: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FetchHistory({ history, onSelect, onRemove }: FetchHistoryProps) {
|
||||||
|
if (history.length === 0) return null;
|
||||||
|
|
||||||
|
const getTypeLabel = (type: string) => {
|
||||||
|
switch (type) {
|
||||||
|
case "track":
|
||||||
|
return "Track";
|
||||||
|
case "album":
|
||||||
|
return "Album";
|
||||||
|
case "playlist":
|
||||||
|
return "Playlist";
|
||||||
|
case "artist":
|
||||||
|
return "Artist";
|
||||||
|
default:
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<span className="text-sm text-muted-foreground">Recent Fetches</span>
|
||||||
|
<div className="flex gap-2 overflow-x-auto pb-2 pt-2">
|
||||||
|
{history.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className="relative shrink-0 w-[130px] group cursor-pointer rounded-lg border bg-card hover:bg-accent transition-colors overflow-visible"
|
||||||
|
onClick={() => onSelect(item)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute -top-1.5 -right-1.5 z-10 w-5 h-5 rounded-full bg-red-500 hover:bg-red-600 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-all cursor-pointer shadow-sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onRemove(item.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3 text-red-900" strokeWidth={3} />
|
||||||
|
</button>
|
||||||
|
<div className="p-2">
|
||||||
|
<div className="aspect-square w-full rounded-md overflow-hidden mb-2 bg-muted">
|
||||||
|
{item.image ? (
|
||||||
|
<img
|
||||||
|
src={item.image}
|
||||||
|
alt={item.name}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex items-center justify-center text-muted-foreground text-xs">
|
||||||
|
No Image
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<p className="text-xs font-medium truncate" title={item.name}>
|
||||||
|
{item.name}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
className="text-xs text-muted-foreground truncate"
|
||||||
|
title={item.artist}
|
||||||
|
>
|
||||||
|
{item.artist}
|
||||||
|
</p>
|
||||||
|
<span className="inline-block text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
|
||||||
|
{getTypeLabel(item.type)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ interface PlaylistInfoProps {
|
|||||||
selectedTracks: string[];
|
selectedTracks: string[];
|
||||||
downloadedTracks: Set<string>;
|
downloadedTracks: Set<string>;
|
||||||
failedTracks: Set<string>;
|
failedTracks: Set<string>;
|
||||||
|
skippedTracks: Set<string>;
|
||||||
downloadingTrack: string | null;
|
downloadingTrack: string | null;
|
||||||
isDownloading: boolean;
|
isDownloading: boolean;
|
||||||
bulkDownloadType: "all" | "selected" | null;
|
bulkDownloadType: "all" | "selected" | null;
|
||||||
@@ -38,12 +39,15 @@ interface PlaylistInfoProps {
|
|||||||
onSortChange: (value: string) => void;
|
onSortChange: (value: string) => void;
|
||||||
onToggleTrack: (isrc: string) => void;
|
onToggleTrack: (isrc: string) => void;
|
||||||
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
|
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
|
||||||
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string) => void;
|
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string) => void;
|
||||||
onDownloadAll: () => void;
|
onDownloadAll: () => void;
|
||||||
onDownloadSelected: () => void;
|
onDownloadSelected: () => void;
|
||||||
onStopDownload: () => void;
|
onStopDownload: () => void;
|
||||||
onOpenFolder: () => void;
|
onOpenFolder: () => void;
|
||||||
onPageChange: (page: number) => void;
|
onPageChange: (page: number) => void;
|
||||||
|
onAlbumClick: (album: { id: string; name: string; external_urls: string }) => void;
|
||||||
|
onArtistClick: (artist: { id: string; name: string; external_urls: string }) => void;
|
||||||
|
onTrackClick: (track: TrackMetadata) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PlaylistInfo({
|
export function PlaylistInfo({
|
||||||
@@ -54,6 +58,7 @@ export function PlaylistInfo({
|
|||||||
selectedTracks,
|
selectedTracks,
|
||||||
downloadedTracks,
|
downloadedTracks,
|
||||||
failedTracks,
|
failedTracks,
|
||||||
|
skippedTracks,
|
||||||
downloadingTrack,
|
downloadingTrack,
|
||||||
isDownloading,
|
isDownloading,
|
||||||
bulkDownloadType,
|
bulkDownloadType,
|
||||||
@@ -71,6 +76,9 @@ export function PlaylistInfo({
|
|||||||
onStopDownload,
|
onStopDownload,
|
||||||
onOpenFolder,
|
onOpenFolder,
|
||||||
onPageChange,
|
onPageChange,
|
||||||
|
onAlbumClick,
|
||||||
|
onArtistClick,
|
||||||
|
onTrackClick,
|
||||||
}: PlaylistInfoProps) {
|
}: PlaylistInfoProps) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -154,16 +162,21 @@ export function PlaylistInfo({
|
|||||||
selectedTracks={selectedTracks}
|
selectedTracks={selectedTracks}
|
||||||
downloadedTracks={downloadedTracks}
|
downloadedTracks={downloadedTracks}
|
||||||
failedTracks={failedTracks}
|
failedTracks={failedTracks}
|
||||||
|
skippedTracks={skippedTracks}
|
||||||
downloadingTrack={downloadingTrack}
|
downloadingTrack={downloadingTrack}
|
||||||
isDownloading={isDownloading}
|
isDownloading={isDownloading}
|
||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
itemsPerPage={itemsPerPage}
|
itemsPerPage={itemsPerPage}
|
||||||
showCheckboxes={true}
|
showCheckboxes={true}
|
||||||
hideAlbumColumn={false}
|
hideAlbumColumn={false}
|
||||||
|
folderName={playlistInfo.owner.name}
|
||||||
onToggleTrack={onToggleTrack}
|
onToggleTrack={onToggleTrack}
|
||||||
onToggleSelectAll={onToggleSelectAll}
|
onToggleSelectAll={onToggleSelectAll}
|
||||||
onDownloadTrack={onDownloadTrack}
|
onDownloadTrack={onDownloadTrack}
|
||||||
onPageChange={onPageChange}
|
onPageChange={onPageChange}
|
||||||
|
onAlbumClick={onAlbumClick}
|
||||||
|
onArtistClick={onArtistClick}
|
||||||
|
onTrackClick={onTrackClick}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Search, ArrowUpDown } from "lucide-react";
|
import { Search, ArrowUpDown, XCircle } from "lucide-react";
|
||||||
|
|
||||||
interface SearchAndSortProps {
|
interface SearchAndSortProps {
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
@@ -29,8 +29,17 @@ export function SearchAndSort({
|
|||||||
placeholder="Search tracks..."
|
placeholder="Search tracks..."
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => onSearchChange(e.target.value)}
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
className="pl-10"
|
className="pl-10 pr-8"
|
||||||
/>
|
/>
|
||||||
|
{searchQuery && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
|
||||||
|
onClick={() => onSearchChange("")}
|
||||||
|
>
|
||||||
|
<XCircle className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Select value={sortBy} onValueChange={onSortChange}>
|
<Select value={sortBy} onValueChange={onSortChange}>
|
||||||
<SelectTrigger className="w-[200px] gap-1.5">
|
<SelectTrigger className="w-[200px] gap-1.5">
|
||||||
|
|||||||
@@ -8,62 +8,86 @@ import {
|
|||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
|
import { FetchHistory } from "@/components/FetchHistory";
|
||||||
|
import type { HistoryItem } from "@/components/FetchHistory";
|
||||||
|
|
||||||
interface SearchBarProps {
|
interface SearchBarProps {
|
||||||
url: string;
|
url: string;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
onUrlChange: (url: string) => void;
|
onUrlChange: (url: string) => void;
|
||||||
onFetch: () => void;
|
onFetch: () => void;
|
||||||
|
history: HistoryItem[];
|
||||||
|
onHistorySelect: (item: HistoryItem) => void;
|
||||||
|
onHistoryRemove: (id: string) => void;
|
||||||
|
hasResult: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SearchBar({ url, loading, onUrlChange, onFetch }: SearchBarProps) {
|
export function SearchBar({
|
||||||
|
url,
|
||||||
|
loading,
|
||||||
|
onUrlChange,
|
||||||
|
onFetch,
|
||||||
|
history,
|
||||||
|
onHistorySelect,
|
||||||
|
onHistoryRemove,
|
||||||
|
hasResult,
|
||||||
|
}: SearchBarProps) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="spotify-url">Spotify URL</Label>
|
<div className="flex items-center gap-2">
|
||||||
<Tooltip>
|
<Label htmlFor="spotify-url">Spotify URL</Label>
|
||||||
<TooltipTrigger asChild>
|
<Tooltip>
|
||||||
<Info className="h-4 w-4 text-muted-foreground cursor-help" />
|
<TooltipTrigger asChild>
|
||||||
</TooltipTrigger>
|
<Info className="h-4 w-4 text-muted-foreground cursor-help" />
|
||||||
<TooltipContent side="right">
|
</TooltipTrigger>
|
||||||
<p>Supports track, album, playlist, and artist URLs</p>
|
<TooltipContent side="right">
|
||||||
</TooltipContent>
|
<p>Supports track, album, playlist, and artist URLs</p>
|
||||||
</Tooltip>
|
</TooltipContent>
|
||||||
</div>
|
</Tooltip>
|
||||||
<div className="flex gap-2">
|
</div>
|
||||||
<div className="relative flex-1">
|
<div className="flex gap-2">
|
||||||
<InputWithContext
|
<div className="relative flex-1">
|
||||||
id="spotify-url"
|
<InputWithContext
|
||||||
placeholder="https://open.spotify.com/..."
|
id="spotify-url"
|
||||||
value={url}
|
placeholder="https://open.spotify.com/..."
|
||||||
onChange={(e) => onUrlChange(e.target.value)}
|
value={url}
|
||||||
onKeyDown={(e) => e.key === "Enter" && onFetch()}
|
onChange={(e) => onUrlChange(e.target.value)}
|
||||||
className="pr-8"
|
onKeyDown={(e) => e.key === "Enter" && onFetch()}
|
||||||
/>
|
className="pr-8"
|
||||||
{url && (
|
/>
|
||||||
<button
|
{url && (
|
||||||
type="button"
|
<button
|
||||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
|
type="button"
|
||||||
onClick={() => onUrlChange("")}
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
|
||||||
>
|
onClick={() => onUrlChange("")}
|
||||||
<XCircle className="h-4 w-4" />
|
>
|
||||||
</button>
|
<XCircle className="h-4 w-4" />
|
||||||
)}
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button onClick={onFetch} disabled={loading}>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<Spinner />
|
||||||
|
Fetching...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Search className="h-4 w-4" />
|
||||||
|
Fetch
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={onFetch} disabled={loading}>
|
|
||||||
{loading ? (
|
|
||||||
<>
|
|
||||||
<Spinner />
|
|
||||||
Fetching...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Search className="h-4 w-4" />
|
|
||||||
Fetch
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
{!hasResult && (
|
||||||
|
<FetchHistory
|
||||||
|
history={history}
|
||||||
|
onSelect={onHistorySelect}
|
||||||
|
onRemove={onHistoryRemove}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
@@ -20,34 +19,34 @@ import {
|
|||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { Settings as SettingsIcon, FolderOpen, Save, RotateCcw, Info } from "lucide-react";
|
import { Settings as SettingsIcon, FolderOpen, Save, RotateCcw, Info, X } from "lucide-react";
|
||||||
import { getSettings, getSettingsWithDefaults, saveSettings, resetToDefaultSettings, applyThemeMode, type Settings as SettingsType } from "@/lib/settings";
|
import { getSettings, getSettingsWithDefaults, saveSettings, resetToDefaultSettings, applyThemeMode, type Settings as SettingsType } from "@/lib/settings";
|
||||||
import { themes, applyTheme } from "@/lib/themes";
|
import { themes, applyTheme } from "@/lib/themes";
|
||||||
import { SelectFolder } from "../../wailsjs/go/main/App";
|
import { SelectFolder } from "../../wailsjs/go/main/App";
|
||||||
|
|
||||||
// Service Icons
|
// Service Icons
|
||||||
const TidalIcon = () => (
|
const TidalIcon = () => (
|
||||||
<svg viewBox="0 0 24 24" fill="currentColor" className="inline-block w-[1.1em] h-[1.1em] mr-2">
|
<svg viewBox="0 0 24 24" className="inline-block w-[1.1em] h-[1.1em] mr-2 fill-muted-foreground">
|
||||||
<path d="M4.022 4.5 0 8.516l3.997 3.99 3.997-3.984L4.022 4.5Zm7.956 0L7.994 8.522l4.003 3.984L16 8.484 11.978 4.5Zm8.007 0L24 8.528l-4.003 3.978L16 8.484 19.985 4.5Z"></path>
|
<path d="M4.022 4.5 0 8.516l3.997 3.99 3.997-3.984L4.022 4.5Zm7.956 0L7.994 8.522l4.003 3.984L16 8.484 11.978 4.5Zm8.007 0L24 8.528l-4.003 3.978L16 8.484 19.985 4.5Z"></path>
|
||||||
<path d="m8.012 16.534 3.991 3.966L16 16.49l-4.003-3.984-3.985 4.028Z"></path>
|
<path d="m8.012 16.534 3.991 3.966L16 16.49l-4.003-3.984-3.985 4.028Z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
const DeezerIcon = () => (
|
const DeezerIcon = () => (
|
||||||
<svg viewBox="0 0 24 24" fill="currentColor" className="inline-block w-[1.1em] h-[1.1em] mr-2">
|
<svg viewBox="0 0 24 24" className="inline-block w-[1.1em] h-[1.1em] mr-2 fill-muted-foreground">
|
||||||
<path d="M18.77 5.55c.19-1.07.46-1.75.76-1.75.56 0 1.02 2.34 1.02 5.23 0 2.89-.46 5.23-1.02 5.23-.23 0-.44-.4-.61-1.06-.27 2.43-.83 4.11-1.48 4.11-.5 0-.96-1-1.26-2.6-.2 3.03-.73 5.17-1.33 5.17-.39 0-.73-.85-.99-2.23-.31 2.85-1.03 4.85-1.86 4.85-.83 0-1.55-2-1.86-4.85-.25 1.38-.6 2.23-.99 2.23-.6 0-1.12-2.14-1.33-5.16-.3 1.58-.75 2.6-1.26 2.6-.65 0-1.2-1.68-1.48-4.12-.17.66-.38 1.06-.61 1.06-.56 0-1.02-2.34-1.02-5.23 0-2.89.46-5.23 1.02-5.23.3 0 .57.68.76 1.75C5.53 3.7 6 2.5 6.56 2.5c.66 0 1.22 1.7 1.49 4.17.26-1.8.66-2.94 1.1-2.94.63 0 1.16 2.25 1.36 5.4.36-1.62.9-2.63 1.5-2.63.58 0 1.12 1.01 1.49 2.62.2-3.14.72-5.4 1.35-5.4.44 0 .84 1.15 1.1 2.95.27-2.47.84-4.17 1.49-4.17.55 0 1.03 1.2 1.33 3.05ZM2 8.52c0-1.3.26-2.34.58-2.34.32 0 .57 1.05.57 2.34 0 1.29-.25 2.34-.57 2.34-.32 0-.58-1.05-.58-2.34Zm18.85 0c0-1.3.25-2.34.57-2.34.32 0 .58 1.05.58 2.34 0 1.29-.26 2.34-.58 2.34-.32 0-.57-1.05-.57-2.34Z"></path>
|
<path d="M18.77 5.55c.19-1.07.46-1.75.76-1.75.56 0 1.02 2.34 1.02 5.23 0 2.89-.46 5.23-1.02 5.23-.23 0-.44-.4-.61-1.06-.27 2.43-.83 4.11-1.48 4.11-.5 0-.96-1-1.26-2.6-.2 3.03-.73 5.17-1.33 5.17-.39 0-.73-.85-.99-2.23-.31 2.85-1.03 4.85-1.86 4.85-.83 0-1.55-2-1.86-4.85-.25 1.38-.6 2.23-.99 2.23-.6 0-1.12-2.14-1.33-5.16-.3 1.58-.75 2.6-1.26 2.6-.65 0-1.2-1.68-1.48-4.12-.17.66-.38 1.06-.61 1.06-.56 0-1.02-2.34-1.02-5.23 0-2.89.46-5.23 1.02-5.23.3 0 .57.68.76 1.75C5.53 3.7 6 2.5 6.56 2.5c.66 0 1.22 1.7 1.49 4.17.26-1.8.66-2.94 1.1-2.94.63 0 1.16 2.25 1.36 5.4.36-1.62.9-2.63 1.5-2.63.58 0 1.12 1.01 1.49 2.62.2-3.14.72-5.4 1.35-5.4.44 0 .84 1.15 1.1 2.95.27-2.47.84-4.17 1.49-4.17.55 0 1.03 1.2 1.33 3.05ZM2 8.52c0-1.3.26-2.34.58-2.34.32 0 .57 1.05.57 2.34 0 1.29-.25 2.34-.57 2.34-.32 0-.58-1.05-.58-2.34Zm18.85 0c0-1.3.25-2.34.57-2.34.32 0 .58 1.05.58 2.34 0 1.29-.26 2.34-.58 2.34-.32 0-.57-1.05-.57-2.34Z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
const QobuzIcon = () => (
|
const QobuzIcon = () => (
|
||||||
<svg viewBox="0 0 24 24" fill="currentColor" className="inline-block w-[1.1em] h-[1.1em] mr-2">
|
<svg viewBox="0 0 24 24" className="inline-block w-[1.1em] h-[1.1em] mr-2 fill-muted-foreground">
|
||||||
<path d="M21.744 9.815C19.836 1.261 8.393-1 3.55 6.64-.618 13.214 4 22 11.988 22c2.387 0 4.63-.83 6.394-2.304l2.252 2.252 1.224-1.224-2.252-2.253c1.983-2.407 2.823-5.586 2.138-8.656Zm-3.508 7.297L16.4 15.275c-.786-.787-2.017.432-1.224 1.225L17 18.326C10.29 23.656.5 16 5.16 7.667c3.502-6.264 13.172-4.348 14.707 2.574.529 2.385-.06 4.987-1.63 6.87Z"></path>
|
<path d="M21.744 9.815C19.836 1.261 8.393-1 3.55 6.64-.618 13.214 4 22 11.988 22c2.387 0 4.63-.83 6.394-2.304l2.252 2.252 1.224-1.224-2.252-2.253c1.983-2.407 2.823-5.586 2.138-8.656Zm-3.508 7.297L16.4 15.275c-.786-.787-2.017.432-1.224 1.225L17 18.326C10.29 23.656.5 16 5.16 7.667c3.502-6.264 13.172-4.348 14.707 2.574.529 2.385-.06 4.987-1.63 6.87Z"></path>
|
||||||
<path d="M13.4 8.684a3.59 3.59 0 0 0-4.712 1.9 3.59 3.59 0 0 0 1.9 4.712 3.594 3.594 0 0 0 4.711-1.89 3.598 3.598 0 0 0-1.9-4.722Zm-.737 3.591a.727.727 0 0 1-.965.384.727.727 0 0 1-.384-.965.727.727 0 0 1 .965-.384.73.73 0 0 1 .384.965Z"></path>
|
<path d="M13.4 8.684a3.59 3.59 0 0 0-4.712 1.9 3.59 3.59 0 0 0 1.9 4.712 3.594 3.594 0 0 0 4.711-1.89 3.598 3.598 0 0 0-1.9-4.722Zm-.737 3.591a.727.727 0 0 1-.965.384.727.727 0 0 1-.384-.965.727.727 0 0 1 .965-.384.73.73 0 0 1 .384.965Z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
const AmazonIcon = () => (
|
const AmazonIcon = () => (
|
||||||
<svg viewBox="0 0 24 24" fill="currentColor" style={{ display: "block", width: "1.1em", height: "1.1em" }} className="inline-block mr-2">
|
<svg viewBox="0 0 24 24" className="inline-block w-[1.1em] h-[1.1em] mr-2 fill-muted-foreground">
|
||||||
<path fillRule="evenodd" d="M15.62 11.13c-.15.1-.37.18-.64.18-.42 0-.82-.05-1.21-.18l-.22-.04c-.08 0-.1.04-.1.14v.25c0 .08.02.12.05.17.02.03.07.08.15.1.4.18.84.25 1.33.25.52 0 .91-.12 1.24-.37.32-.25.47-.57.47-.99 0-.3-.08-.52-.23-.72-.15-.17-.4-.34-.74-.47l-.7-.27c-.26-.1-.46-.2-.53-.3a.47.47 0 0 1-.15-.36c0-.38.27-.57.84-.57.32 0 .64.05.94.15l.2.04c.07 0 .12-.04.12-.14v-.25c0-.08-.03-.12-.05-.17-.03-.05-.08-.08-.15-.1-.37-.13-.74-.2-1.11-.2-.47 0-.87.12-1.16.35-.3.22-.45.54-.45.91 0 .57.32.99.97 1.24l.74.27c.24.1.4.17.5.27.09.1.12.2.12.35 0 .2-.08.37-.23.46Zm-3.88-3.55v3.28c-.42.28-.84.42-1.26.42-.27 0-.47-.07-.6-.22-.11-.15-.16-.37-.16-.7V7.59c0-.13-.05-.18-.18-.18h-.52c-.12 0-.17.05-.17.18v3.06c0 .42.1.77.32.99.22.22.55.35.97.35.56 0 1.13-.2 1.68-.6l.05.3c0 .07.02.1.07.12.02.03.07.03.15.03h.37c.12 0 .17-.05.17-.18V7.58c0-.13-.05-.18-.17-.18h-.52c-.15 0-.2.08-.2.18Zm-4.69 4.27h.52c.12 0 .17-.05.17-.17v-3.1c0-.41-.1-.73-.32-.95a1.25 1.25 0 0 0-.94-.35c-.57 0-1.16.2-1.73.62-.2-.42-.57-.62-1.11-.62-.55 0-1.1.2-1.64.57l-.04-.27c0-.08-.03-.1-.08-.13-.02-.02-.07-.02-.12-.02h-.4c-.12 0-.17.05-.17.17v4.1c0 .13.05.18.17.18h.52c.12 0 .17-.05.17-.18V8.37c.42-.25.84-.4 1.29-.4.25 0 .42.08.52.22.1.15.17.35.17.65v2.84c0 .12.05.17.17.17h.52c.13 0 .18-.05.18-.17V8.37c.44-.27.86-.4 1.28-.4.25 0 .42.08.52.22.1.15.17.35.17.65v2.84c0 .12.05.17.18.17Zm13.47 3.29a21.8 21.8 0 0 1-8.3 1.7c-3.96 0-7.8-1.08-10.88-2.89a.35.35 0 0 0-.15-.05c-.17 0-.27.2-.1.37a16.11 16.11 0 0 0 10.87 4.16c3.02 0 6.5-.94 8.9-2.72.42-.3.08-.74-.34-.57Zm-.08-6.74c.22-.26.57-.38 1.06-.38.25 0 .5.03.72.1l.15.02c.07 0 .12-.04.12-.17v-.25c0-.07-.02-.14-.05-.17a.54.54 0 0 0-.12-.1c-.32-.07-.64-.15-.94-.15-.7 0-1.21.2-1.6.62-.38.4-.57 1-.57 1.73 0 .74.17 1.31.54 1.7.37.4.89.6 1.58.6.37 0 .72-.05.99-.17.07-.03.12-.05.15-.1.02-.03.02-.1.02-.17v-.25c0-.13-.05-.17-.12-.17-.03 0-.07 0-.12.02-.28.07-.55.12-.8.12-.46 0-.81-.12-1.03-.37-.23-.24-.32-.64-.32-1.16v-.12c.02-.55.12-.94.34-1.19Z" clipRule="evenodd"></path>
|
<path fillRule="evenodd" d="M15.62 11.13c-.15.1-.37.18-.64.18-.42 0-.82-.05-1.21-.18l-.22-.04c-.08 0-.1.04-.1.14v.25c0 .08.02.12.05.17.02.03.07.08.15.1.4.18.84.25 1.33.25.52 0 .91-.12 1.24-.37.32-.25.47-.57.47-.99 0-.3-.08-.52-.23-.72-.15-.17-.4-.34-.74-.47l-.7-.27c-.26-.1-.46-.2-.53-.3a.47.47 0 0 1-.15-.36c0-.38.27-.57.84-.57.32 0 .64.05.94.15l.2.04c.07 0 .12-.04.12-.14v-.25c0-.08-.03-.12-.05-.17-.03-.05-.08-.08-.15-.1-.37-.13-.74-.2-1.11-.2-.47 0-.87.12-1.16.35-.3.22-.45.54-.45.91 0 .57.32.99.97 1.24l.74.27c.24.1.4.17.5.27.09.1.12.2.12.35 0 .2-.08.37-.23.46Zm-3.88-3.55v3.28c-.42.28-.84.42-1.26.42-.27 0-.47-.07-.6-.22-.11-.15-.16-.37-.16-.7V7.59c0-.13-.05-.18-.18-.18h-.52c-.12 0-.17.05-.17.18v3.06c0 .42.1.77.32.99.22.22.55.35.97.35.56 0 1.13-.2 1.68-.6l.05.3c0 .07.02.1.07.12.02.03.07.03.15.03h.37c.12 0 .17-.05.17-.18V7.58c0-.13-.05-.18-.17-.18h-.52c-.15 0-.2.08-.2.18Zm-4.69 4.27h.52c.12 0 .17-.05.17-.17v-3.1c0-.41-.1-.73-.32-.95a1.25 1.25 0 0 0-.94-.35c-.57 0-1.16.2-1.73.62-.2-.42-.57-.62-1.11-.62-.55 0-1.1.2-1.64.57l-.04-.27c0-.08-.03-.1-.08-.13-.02-.02-.07-.02-.12-.02h-.4c-.12 0-.17.05-.17.17v4.1c0 .13.05.18.17.18h.52c.12 0 .17-.05.17-.18V8.37c.42-.25.84-.4 1.29-.4.25 0 .42.08.52.22.1.15.17.35.17.65v2.84c0 .12.05.17.17.17h.52c.13 0 .18-.05.18-.17V8.37c.44-.27.86-.4 1.28-.4.25 0 .42.08.52.22.1.15.17.35.17.65v2.84c0 .12.05.17.18.17Zm13.47 3.29a21.8 21.8 0 0 1-8.3 1.7c-3.96 0-7.8-1.08-10.88-2.89a.35.35 0 0 0-.15-.05c-.17 0-.27.2-.1.37a16.11 16.11 0 0 0 10.87 4.16c3.02 0 6.5-.94 8.9-2.72.42-.3.08-.74-.34-.57Zm-.08-6.74c.22-.26.57-.38 1.06-.38.25 0 .5.03.72.1l.15.02c.07 0 .12-.04.12-.17v-.25c0-.07-.02-.14-.05-.17a.54.54 0 0 0-.12-.1c-.32-.07-.64-.15-.94-.15-.7 0-1.21.2-1.6.62-.38.4-.57 1-.57 1.73 0 .74.17 1.31.54 1.7.37.4.89.6 1.58.6.37 0 .72-.05.99-.17.07-.03.12-.05.15-.1.02-.03.02-.1.02-.17v-.25c0-.13-.05-.17-.12-.17-.03 0-.07 0-.12.02-.28.07-.55.12-.8.12-.46 0-.81-.12-1.03-.37-.23-.24-.32-.64-.32-1.16v-.12c.02-.55.12-.94.34-1.19Z" clipRule="evenodd"></path>
|
||||||
<path fillRule="evenodd" d="M21.55 17.46c1.29-1.09 1.64-3.33 1.36-3.68-.12-.15-.71-.3-1.45-.3-.8 0-1.73.18-2.45.67-.22.15-.17.35.05.32.76-.1 2.5-.3 2.82.1.3.4-.35 2.03-.65 2.74-.07.23.1.3.32.15ZM18.12 7.4h-.52c-.12 0-.17.05-.17.18v4.1c0 .12.05.17.17.17h.52c.12 0 .17-.05.17-.17v-4.1c0-.1-.05-.18-.17-.18Zm.15-1.68a.58.58 0 0 0-.42-.15c-.18 0-.3.05-.4.15a.5.5 0 0 0-.15.37c0 .15.05.3.15.37.1.1.22.15.4.15.17 0 .3-.05.4-.15a.5.5 0 0 0 .14-.37c0-.15-.02-.3-.12-.37Z" clipRule="evenodd"></path>
|
<path fillRule="evenodd" d="M21.55 17.46c1.29-1.09 1.64-3.33 1.36-3.68-.12-.15-.71-.3-1.45-.3-.8 0-1.73.18-2.45.67-.22.15-.17.35.05.32.76-.1 2.5-.3 2.82.1.3.4-.35 2.03-.65 2.74-.07.23.1.3.32.15ZM18.12 7.4h-.52c-.12 0-.17.05-.17.18v4.1c0 .12.05.17.17.17h.52c.12 0 .17-.05.17-.17v-4.1c0-.1-.05-.18-.17-.18Zm.15-1.68a.58.58 0 0 0-.42-.15c-.18 0-.3.05-.4.15a.5.5 0 0 0-.15.37c0 .15.05.3.15.37.1.1.22.15.4.15.17 0 .3-.05.4-.15a.5.5 0 0 0 .14-.37c0-.15-.02-.3-.12-.37Z" clipRule="evenodd"></path>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -58,6 +57,7 @@ export function Settings() {
|
|||||||
const [savedSettings, setSavedSettings] = useState<SettingsType>(getSettings());
|
const [savedSettings, setSavedSettings] = useState<SettingsType>(getSettings());
|
||||||
const [tempSettings, setTempSettings] = useState<SettingsType>(savedSettings);
|
const [tempSettings, setTempSettings] = useState<SettingsType>(savedSettings);
|
||||||
const [, setIsLoadingDefaults] = useState(false);
|
const [, setIsLoadingDefaults] = useState(false);
|
||||||
|
const [isDark, setIsDark] = useState(document.documentElement.classList.contains('dark'));
|
||||||
|
|
||||||
// Apply saved settings
|
// Apply saved settings
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -86,12 +86,20 @@ export function Settings() {
|
|||||||
applyThemeMode(tempSettings.themeMode);
|
applyThemeMode(tempSettings.themeMode);
|
||||||
applyTheme(tempSettings.theme);
|
applyTheme(tempSettings.theme);
|
||||||
|
|
||||||
|
// Update isDark state after theme is applied
|
||||||
|
setTimeout(() => {
|
||||||
|
setIsDark(document.documentElement.classList.contains('dark'));
|
||||||
|
}, 0);
|
||||||
|
|
||||||
// Setup listener for system theme changes during preview
|
// Setup listener for system theme changes during preview
|
||||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||||
const handleChange = () => {
|
const handleChange = () => {
|
||||||
if (tempSettings.themeMode === "auto") {
|
if (tempSettings.themeMode === "auto") {
|
||||||
applyThemeMode("auto");
|
applyThemeMode("auto");
|
||||||
applyTheme(tempSettings.theme);
|
applyTheme(tempSettings.theme);
|
||||||
|
setTimeout(() => {
|
||||||
|
setIsDark(document.documentElement.classList.contains('dark'));
|
||||||
|
}, 0);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -199,11 +207,19 @@ export function Settings() {
|
|||||||
<SettingsIcon className="h-5 w-5" />
|
<SettingsIcon className="h-5 w-5" />
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="sm:max-w-[700px] flex flex-col" aria-describedby={undefined}>
|
<DialogContent className="sm:max-w-[600px] flex flex-col p-6 [&>button]:hidden" aria-describedby={undefined}>
|
||||||
<DialogHeader>
|
<div className="absolute right-4 top-4">
|
||||||
<DialogTitle>Settings</DialogTitle>
|
<Button
|
||||||
</DialogHeader>
|
variant="ghost"
|
||||||
<div className="grid grid-cols-2 gap-6 py-2">
|
size="icon"
|
||||||
|
className="h-6 w-6 opacity-70 hover:opacity-100"
|
||||||
|
onClick={handleCancel}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<DialogTitle className="text-sm font-medium">Settings</DialogTitle>
|
||||||
|
<div className="grid grid-cols-[1.2fr_0.8fr] gap-6 py-2">
|
||||||
{/* Left Column */}
|
{/* Left Column */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Download Path */}
|
{/* Download Path */}
|
||||||
@@ -290,8 +306,12 @@ export function Settings() {
|
|||||||
<SelectItem key={theme.name} value={theme.name}>
|
<SelectItem key={theme.name} value={theme.name}>
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
className="w-3 h-3 rounded-full"
|
className="w-3 h-3 rounded-full border border-border"
|
||||||
style={{ backgroundColor: theme.cssVars.light.primary }}
|
style={{
|
||||||
|
backgroundColor: isDark
|
||||||
|
? theme.cssVars.dark.primary
|
||||||
|
: theme.cssVars.light.primary
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
{theme.label}
|
{theme.label}
|
||||||
</span>
|
</span>
|
||||||
@@ -353,7 +373,15 @@ export function Settings() {
|
|||||||
checked={tempSettings.artistSubfolder}
|
checked={tempSettings.artistSubfolder}
|
||||||
onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, artistSubfolder: checked as boolean }))}
|
onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, artistSubfolder: checked as boolean }))}
|
||||||
/>
|
/>
|
||||||
<Label htmlFor="artist-subfolder" className="cursor-pointer text-sm">Artist Subfolder <span className="font-normal">(Playlist only)</span></Label>
|
<Label htmlFor="artist-subfolder" className="cursor-pointer text-sm">Artist Subfolder</Label>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top" className="max-w-xs">
|
||||||
|
<p className="text-xs text-center">Playlist only</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@@ -361,22 +389,30 @@ export function Settings() {
|
|||||||
checked={tempSettings.albumSubfolder}
|
checked={tempSettings.albumSubfolder}
|
||||||
onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, albumSubfolder: checked as boolean }))}
|
onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, albumSubfolder: checked as boolean }))}
|
||||||
/>
|
/>
|
||||||
<Label htmlFor="album-subfolder" className="cursor-pointer text-sm">Album Subfolder <span className="font-normal">(Playlist & Discography)</span></Label>
|
<Label htmlFor="album-subfolder" className="cursor-pointer text-sm">Album Subfolder</Label>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top" className="max-w-xs">
|
||||||
|
<p className="text-xs text-center">Playlist & Discography</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter className="gap-2 sm:justify-between">
|
<DialogFooter className="gap-2 sm:justify-between">
|
||||||
<Button variant="outline" onClick={handleReset} size="sm" className="gap-1.5">
|
<Button variant="outline" onClick={handleReset} className="gap-1.5">
|
||||||
<RotateCcw className="h-3.5 w-3.5" />
|
<RotateCcw className="h-4 w-4" />
|
||||||
Reset to Default
|
Reset to Default
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button variant="outline" onClick={handleCancel} size="sm">
|
<Button variant="outline" onClick={handleCancel}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleSave} size="sm" className="gap-1.5">
|
<Button onClick={handleSave} className="gap-1.5">
|
||||||
<Save className="h-3.5 w-3.5" />
|
<Save className="h-4 w-4" />
|
||||||
Save Changes
|
Save Changes
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Download, CheckCircle, XCircle } from "lucide-react";
|
import { Download, CheckCircle, XCircle, SkipForward } from "lucide-react";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import {
|
import {
|
||||||
Pagination,
|
Pagination,
|
||||||
@@ -19,16 +19,22 @@ interface TrackListProps {
|
|||||||
selectedTracks: string[];
|
selectedTracks: string[];
|
||||||
downloadedTracks: Set<string>;
|
downloadedTracks: Set<string>;
|
||||||
failedTracks: Set<string>;
|
failedTracks: Set<string>;
|
||||||
|
skippedTracks: Set<string>;
|
||||||
downloadingTrack: string | null;
|
downloadingTrack: string | null;
|
||||||
isDownloading: boolean;
|
isDownloading: boolean;
|
||||||
currentPage: number;
|
currentPage: number;
|
||||||
itemsPerPage: number;
|
itemsPerPage: number;
|
||||||
showCheckboxes?: boolean;
|
showCheckboxes?: boolean;
|
||||||
hideAlbumColumn?: boolean;
|
hideAlbumColumn?: boolean;
|
||||||
|
folderName?: string;
|
||||||
|
isArtistDiscography?: boolean;
|
||||||
onToggleTrack: (isrc: string) => void;
|
onToggleTrack: (isrc: string) => void;
|
||||||
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
|
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
|
||||||
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string) => void;
|
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, isArtistDiscography?: boolean) => void;
|
||||||
onPageChange: (page: number) => void;
|
onPageChange: (page: number) => void;
|
||||||
|
onAlbumClick?: (album: { id: string; name: string; external_urls: string }) => void;
|
||||||
|
onArtistClick?: (artist: { id: string; name: string; external_urls: string }) => void;
|
||||||
|
onTrackClick?: (track: TrackMetadata) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TrackList({
|
export function TrackList({
|
||||||
@@ -38,16 +44,22 @@ export function TrackList({
|
|||||||
selectedTracks,
|
selectedTracks,
|
||||||
downloadedTracks,
|
downloadedTracks,
|
||||||
failedTracks,
|
failedTracks,
|
||||||
|
skippedTracks,
|
||||||
downloadingTrack,
|
downloadingTrack,
|
||||||
isDownloading,
|
isDownloading,
|
||||||
currentPage,
|
currentPage,
|
||||||
itemsPerPage,
|
itemsPerPage,
|
||||||
showCheckboxes = false,
|
showCheckboxes = false,
|
||||||
hideAlbumColumn = false,
|
hideAlbumColumn = false,
|
||||||
|
folderName,
|
||||||
|
isArtistDiscography = false,
|
||||||
onToggleTrack,
|
onToggleTrack,
|
||||||
onToggleSelectAll,
|
onToggleSelectAll,
|
||||||
onDownloadTrack,
|
onDownloadTrack,
|
||||||
onPageChange,
|
onPageChange,
|
||||||
|
onAlbumClick,
|
||||||
|
onArtistClick,
|
||||||
|
onTrackClick,
|
||||||
}: TrackListProps) {
|
}: TrackListProps) {
|
||||||
let filteredTracks = tracks.filter((track) => {
|
let filteredTracks = tracks.filter((track) => {
|
||||||
if (!searchQuery) return true;
|
if (!searchQuery) return true;
|
||||||
@@ -163,23 +175,85 @@ export function TrackList({
|
|||||||
)}
|
)}
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-medium">{track.name}</span>
|
{onTrackClick ? (
|
||||||
{downloadedTracks.has(track.isrc) && (
|
<span
|
||||||
|
className="font-medium cursor-pointer hover:underline"
|
||||||
|
onClick={() => onTrackClick(track)}
|
||||||
|
>
|
||||||
|
{track.name}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="font-medium">{track.name}</span>
|
||||||
|
)}
|
||||||
|
{skippedTracks.has(track.isrc) ? (
|
||||||
|
<SkipForward className="h-4 w-4 text-yellow-500 shrink-0" />
|
||||||
|
) : downloadedTracks.has(track.isrc) ? (
|
||||||
<CheckCircle className="h-4 w-4 text-green-500 shrink-0" />
|
<CheckCircle className="h-4 w-4 text-green-500 shrink-0" />
|
||||||
)}
|
) : failedTracks.has(track.isrc) ? (
|
||||||
{failedTracks.has(track.isrc) && (
|
|
||||||
<XCircle className="h-4 w-4 text-red-500 shrink-0" />
|
<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}
|
{track.artists_data && track.artists_data.length > 0 ? (
|
||||||
|
track.artists_data.map((artist, i, arr) => (
|
||||||
|
<span key={artist.id}>
|
||||||
|
{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
|
||||||
|
)}
|
||||||
|
{i < arr.length - 1 && ", "}
|
||||||
|
</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>
|
||||||
</td>
|
</td>
|
||||||
{!hideAlbumColumn && (
|
{!hideAlbumColumn && (
|
||||||
<td className="p-4 align-middle text-sm text-muted-foreground hidden md:table-cell">
|
<td className="p-4 align-middle text-sm text-muted-foreground hidden md:table-cell">
|
||||||
{track.album_name}
|
{onAlbumClick && track.album_id && track.album_url ? (
|
||||||
|
<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
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
)}
|
)}
|
||||||
<td className="p-4 align-middle text-sm text-muted-foreground hidden lg:table-cell">
|
<td className="p-4 align-middle text-sm text-muted-foreground hidden lg:table-cell">
|
||||||
@@ -189,7 +263,7 @@ export function TrackList({
|
|||||||
{track.isrc && (
|
{track.isrc && (
|
||||||
<Button
|
<Button
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
onDownloadTrack(track.isrc, track.name, track.artists, track.album_name)
|
onDownloadTrack(track.isrc, track.name, track.artists, track.album_name, track.spotify_id, folderName, isArtistDiscography)
|
||||||
}
|
}
|
||||||
size="sm"
|
size="sm"
|
||||||
className="gap-1.5"
|
className="gap-1.5"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { downloadTrack } from "@/lib/api";
|
|||||||
import { getSettings } from "@/lib/settings";
|
import { getSettings } from "@/lib/settings";
|
||||||
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 { logger } from "@/lib/logger";
|
||||||
import type { TrackMetadata } from "@/types/api";
|
import type { TrackMetadata } from "@/types/api";
|
||||||
|
|
||||||
export function useDownload() {
|
export function useDownload() {
|
||||||
@@ -12,6 +13,7 @@ export function useDownload() {
|
|||||||
const [bulkDownloadType, setBulkDownloadType] = useState<"all" | "selected" | null>(null);
|
const [bulkDownloadType, setBulkDownloadType] = useState<"all" | "selected" | null>(null);
|
||||||
const [downloadedTracks, setDownloadedTracks] = useState<Set<string>>(new Set());
|
const [downloadedTracks, setDownloadedTracks] = useState<Set<string>>(new Set());
|
||||||
const [failedTracks, setFailedTracks] = useState<Set<string>>(new Set());
|
const [failedTracks, setFailedTracks] = useState<Set<string>>(new Set());
|
||||||
|
const [skippedTracks, setSkippedTracks] = useState<Set<string>>(new Set());
|
||||||
const [currentDownloadInfo, setCurrentDownloadInfo] = useState<{
|
const [currentDownloadInfo, setCurrentDownloadInfo] = useState<{
|
||||||
name: string;
|
name: string;
|
||||||
artists: string;
|
artists: string;
|
||||||
@@ -73,6 +75,7 @@ export function useDownload() {
|
|||||||
// Try Tidal first
|
// Try Tidal first
|
||||||
if (streamingURLs?.tidal_url) {
|
if (streamingURLs?.tidal_url) {
|
||||||
try {
|
try {
|
||||||
|
logger.debug(`trying tidal for: ${trackName} - ${artistName}`);
|
||||||
const tidalResponse = await downloadTrack({
|
const tidalResponse = await downloadTrack({
|
||||||
isrc,
|
isrc,
|
||||||
service: "tidal",
|
service: "tidal",
|
||||||
@@ -90,17 +93,19 @@ export function useDownload() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (tidalResponse.success) {
|
if (tidalResponse.success) {
|
||||||
|
logger.success(`tidal: ${trackName} - ${artistName}`);
|
||||||
return tidalResponse;
|
return tidalResponse;
|
||||||
}
|
}
|
||||||
console.log("Tidal failed, trying Deezer...");
|
logger.warning(`tidal failed, trying deezer...`);
|
||||||
} catch (tidalErr) {
|
} catch (tidalErr) {
|
||||||
console.log("Tidal error:", tidalErr);
|
logger.error(`tidal error: ${tidalErr}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try Deezer second
|
// Try Deezer second
|
||||||
if (streamingURLs?.deezer_url) {
|
if (streamingURLs?.deezer_url) {
|
||||||
try {
|
try {
|
||||||
|
logger.debug(`trying deezer for: ${trackName} - ${artistName}`);
|
||||||
const deezerResponse = await downloadTrack({
|
const deezerResponse = await downloadTrack({
|
||||||
isrc,
|
isrc,
|
||||||
service: "deezer",
|
service: "deezer",
|
||||||
@@ -118,17 +123,19 @@ export function useDownload() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (deezerResponse.success) {
|
if (deezerResponse.success) {
|
||||||
|
logger.success(`deezer: ${trackName} - ${artistName}`);
|
||||||
return deezerResponse;
|
return deezerResponse;
|
||||||
}
|
}
|
||||||
console.log("Deezer failed, trying Amazon...");
|
logger.warning(`deezer failed, trying amazon...`);
|
||||||
} catch (deezerErr) {
|
} catch (deezerErr) {
|
||||||
console.log("Deezer error:", deezerErr);
|
logger.error(`deezer error: ${deezerErr}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try Amazon third
|
// Try Amazon third
|
||||||
if (streamingURLs?.amazon_url) {
|
if (streamingURLs?.amazon_url) {
|
||||||
try {
|
try {
|
||||||
|
logger.debug(`trying amazon for: ${trackName} - ${artistName}`);
|
||||||
const amazonResponse = await downloadTrack({
|
const amazonResponse = await downloadTrack({
|
||||||
isrc,
|
isrc,
|
||||||
service: "amazon",
|
service: "amazon",
|
||||||
@@ -146,15 +153,17 @@ export function useDownload() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (amazonResponse.success) {
|
if (amazonResponse.success) {
|
||||||
|
logger.success(`amazon: ${trackName} - ${artistName}`);
|
||||||
return amazonResponse;
|
return amazonResponse;
|
||||||
}
|
}
|
||||||
console.log("Amazon failed, trying Qobuz...");
|
logger.warning(`amazon failed, trying qobuz...`);
|
||||||
} catch (amazonErr) {
|
} catch (amazonErr) {
|
||||||
console.log("Amazon error:", amazonErr);
|
logger.error(`amazon error: ${amazonErr}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try Qobuz as last fallback
|
// Try Qobuz as last fallback
|
||||||
|
logger.debug(`trying qobuz (fallback) for: ${trackName} - ${artistName}`);
|
||||||
service = "qobuz";
|
service = "qobuz";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,26 +188,29 @@ export function useDownload() {
|
|||||||
trackName?: string,
|
trackName?: string,
|
||||||
artistName?: string,
|
artistName?: string,
|
||||||
albumName?: string,
|
albumName?: string,
|
||||||
spotifyId?: string
|
spotifyId?: string,
|
||||||
|
playlistName?: string,
|
||||||
|
isArtistDiscography?: boolean
|
||||||
) => {
|
) => {
|
||||||
if (!isrc) {
|
if (!isrc) {
|
||||||
toast.error("No ISRC found for this track");
|
toast.error("No ISRC found for this track");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.info(`starting download: ${trackName} - ${artistName}`);
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
setDownloadingTrack(isrc);
|
setDownloadingTrack(isrc);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Single track download - no position parameter
|
// Single track download - use playlistName if provided for folder structure
|
||||||
const response = await downloadWithAutoFallback(
|
const response = await downloadWithAutoFallback(
|
||||||
isrc,
|
isrc,
|
||||||
settings,
|
settings,
|
||||||
trackName,
|
trackName,
|
||||||
artistName,
|
artistName,
|
||||||
albumName,
|
albumName,
|
||||||
undefined,
|
playlistName,
|
||||||
false,
|
isArtistDiscography,
|
||||||
undefined, // Don't pass position for single track
|
undefined, // Don't pass position for single track
|
||||||
spotifyId
|
spotifyId
|
||||||
);
|
);
|
||||||
@@ -206,15 +218,23 @@ export function useDownload() {
|
|||||||
if (response.success) {
|
if (response.success) {
|
||||||
if (response.already_exists) {
|
if (response.already_exists) {
|
||||||
toast.info(response.message);
|
toast.info(response.message);
|
||||||
|
setSkippedTracks((prev) => new Set(prev).add(isrc));
|
||||||
} else {
|
} else {
|
||||||
toast.success(response.message);
|
toast.success(response.message);
|
||||||
}
|
}
|
||||||
setDownloadedTracks((prev) => new Set(prev).add(isrc));
|
setDownloadedTracks((prev) => new Set(prev).add(isrc));
|
||||||
|
setFailedTracks((prev) => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
newSet.delete(isrc);
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
toast.error(response.error || "Download failed");
|
toast.error(response.error || "Download failed");
|
||||||
|
setFailedTracks((prev) => new Set(prev).add(isrc));
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err instanceof Error ? err.message : "Download failed");
|
toast.error(err instanceof Error ? err.message : "Download failed");
|
||||||
|
setFailedTracks((prev) => new Set(prev).add(isrc));
|
||||||
} finally {
|
} finally {
|
||||||
setDownloadingTrack(null);
|
setDownloadingTrack(null);
|
||||||
}
|
}
|
||||||
@@ -231,6 +251,7 @@ export function useDownload() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.info(`starting batch download: ${selectedTracks.length} selected tracks`);
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
setIsDownloading(true);
|
setIsDownloading(true);
|
||||||
setBulkDownloadType("selected");
|
setBulkDownloadType("selected");
|
||||||
@@ -275,9 +296,11 @@ export function useDownload() {
|
|||||||
if (response.success) {
|
if (response.success) {
|
||||||
if (response.already_exists) {
|
if (response.already_exists) {
|
||||||
skippedCount++;
|
skippedCount++;
|
||||||
console.log(`Skipped: ${track?.name} - ${track?.artists} (already exists)`);
|
logger.info(`skipped: ${track?.name} - ${track?.artists} (already exists)`);
|
||||||
|
setSkippedTracks((prev) => new Set(prev).add(isrc));
|
||||||
} else {
|
} else {
|
||||||
successCount++;
|
successCount++;
|
||||||
|
logger.success(`downloaded: ${track?.name} - ${track?.artists}`);
|
||||||
}
|
}
|
||||||
setDownloadedTracks((prev) => new Set(prev).add(isrc));
|
setDownloadedTracks((prev) => new Set(prev).add(isrc));
|
||||||
setFailedTracks((prev) => {
|
setFailedTracks((prev) => {
|
||||||
@@ -287,10 +310,12 @@ export function useDownload() {
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
errorCount++;
|
errorCount++;
|
||||||
|
logger.error(`failed: ${track?.name} - ${track?.artists}`);
|
||||||
setFailedTracks((prev) => new Set(prev).add(isrc));
|
setFailedTracks((prev) => new Set(prev).add(isrc));
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
errorCount++;
|
errorCount++;
|
||||||
|
logger.error(`error: ${track?.name} - ${err}`);
|
||||||
setFailedTracks((prev) => new Set(prev).add(isrc));
|
setFailedTracks((prev) => new Set(prev).add(isrc));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -304,6 +329,7 @@ export function useDownload() {
|
|||||||
shouldStopDownloadRef.current = false;
|
shouldStopDownloadRef.current = false;
|
||||||
|
|
||||||
// Build summary message
|
// Build summary message
|
||||||
|
logger.info(`batch complete: ${successCount} downloaded, ${skippedCount} skipped, ${errorCount} failed`);
|
||||||
if (errorCount === 0 && skippedCount === 0) {
|
if (errorCount === 0 && skippedCount === 0) {
|
||||||
toast.success(`Downloaded ${successCount} tracks successfully`);
|
toast.success(`Downloaded ${successCount} tracks successfully`);
|
||||||
} else if (errorCount === 0 && successCount === 0) {
|
} else if (errorCount === 0 && successCount === 0) {
|
||||||
@@ -334,6 +360,7 @@ export function useDownload() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.info(`starting batch download: ${tracksWithIsrc.length} tracks`);
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
setIsDownloading(true);
|
setIsDownloading(true);
|
||||||
setBulkDownloadType("all");
|
setBulkDownloadType("all");
|
||||||
@@ -373,9 +400,11 @@ export function useDownload() {
|
|||||||
if (response.success) {
|
if (response.success) {
|
||||||
if (response.already_exists) {
|
if (response.already_exists) {
|
||||||
skippedCount++;
|
skippedCount++;
|
||||||
console.log(`Skipped: ${track.name} - ${track.artists} (already exists)`);
|
logger.info(`skipped: ${track.name} - ${track.artists} (already exists)`);
|
||||||
|
setSkippedTracks((prev) => new Set(prev).add(track.isrc));
|
||||||
} else {
|
} else {
|
||||||
successCount++;
|
successCount++;
|
||||||
|
logger.success(`downloaded: ${track.name} - ${track.artists}`);
|
||||||
}
|
}
|
||||||
setDownloadedTracks((prev) => new Set(prev).add(track.isrc));
|
setDownloadedTracks((prev) => new Set(prev).add(track.isrc));
|
||||||
setFailedTracks((prev) => {
|
setFailedTracks((prev) => {
|
||||||
@@ -385,10 +414,12 @@ export function useDownload() {
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
errorCount++;
|
errorCount++;
|
||||||
|
logger.error(`failed: ${track.name} - ${track.artists}`);
|
||||||
setFailedTracks((prev) => new Set(prev).add(track.isrc));
|
setFailedTracks((prev) => new Set(prev).add(track.isrc));
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
errorCount++;
|
errorCount++;
|
||||||
|
logger.error(`error: ${track.name} - ${err}`);
|
||||||
setFailedTracks((prev) => new Set(prev).add(track.isrc));
|
setFailedTracks((prev) => new Set(prev).add(track.isrc));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -402,6 +433,7 @@ export function useDownload() {
|
|||||||
shouldStopDownloadRef.current = false;
|
shouldStopDownloadRef.current = false;
|
||||||
|
|
||||||
// Build summary message
|
// Build summary message
|
||||||
|
logger.info(`batch complete: ${successCount} downloaded, ${skippedCount} skipped, ${errorCount} failed`);
|
||||||
if (errorCount === 0 && skippedCount === 0) {
|
if (errorCount === 0 && skippedCount === 0) {
|
||||||
toast.success(`Downloaded ${successCount} tracks successfully`);
|
toast.success(`Downloaded ${successCount} tracks successfully`);
|
||||||
} else if (errorCount === 0 && successCount === 0) {
|
} else if (errorCount === 0 && successCount === 0) {
|
||||||
@@ -421,6 +453,7 @@ export function useDownload() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleStopDownload = () => {
|
const handleStopDownload = () => {
|
||||||
|
logger.info("download stopped by user");
|
||||||
shouldStopDownloadRef.current = true;
|
shouldStopDownloadRef.current = true;
|
||||||
toast.info("Stopping download...");
|
toast.info("Stopping download...");
|
||||||
};
|
};
|
||||||
@@ -428,6 +461,7 @@ export function useDownload() {
|
|||||||
const resetDownloadedTracks = () => {
|
const resetDownloadedTracks = () => {
|
||||||
setDownloadedTracks(new Set());
|
setDownloadedTracks(new Set());
|
||||||
setFailedTracks(new Set());
|
setFailedTracks(new Set());
|
||||||
|
setSkippedTracks(new Set());
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -437,6 +471,7 @@ export function useDownload() {
|
|||||||
bulkDownloadType,
|
bulkDownloadType,
|
||||||
downloadedTracks,
|
downloadedTracks,
|
||||||
failedTracks,
|
failedTracks,
|
||||||
|
skippedTracks,
|
||||||
currentDownloadInfo,
|
currentDownloadInfo,
|
||||||
handleDownloadTrack,
|
handleDownloadTrack,
|
||||||
handleDownloadSelected,
|
handleDownloadSelected,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
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 type { SpotifyMetadataResponse } from "@/types/api";
|
import type { SpotifyMetadataResponse } from "@/types/api";
|
||||||
|
|
||||||
export function useMetadata() {
|
export function useMetadata() {
|
||||||
@@ -15,17 +16,52 @@ export function useMetadata() {
|
|||||||
name: string;
|
name: string;
|
||||||
external_urls: string;
|
external_urls: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
const [pendingArtistName, setPendingArtistName] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const getUrlType = (url: string): string => {
|
||||||
|
if (url.includes("/track/")) return "track";
|
||||||
|
if (url.includes("/album/")) return "album";
|
||||||
|
if (url.includes("/playlist/")) return "playlist";
|
||||||
|
if (url.includes("/artist/")) return "artist";
|
||||||
|
return "unknown";
|
||||||
|
};
|
||||||
|
|
||||||
const fetchMetadataDirectly = async (url: string) => {
|
const fetchMetadataDirectly = async (url: string) => {
|
||||||
|
const urlType = getUrlType(url);
|
||||||
|
logger.info(`fetching ${urlType} metadata...`);
|
||||||
|
logger.debug(`url: ${url}`);
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setMetadata(null);
|
setMetadata(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const startTime = Date.now();
|
||||||
const data = await fetchSpotifyMetadata(url);
|
const data = await fetchSpotifyMetadata(url);
|
||||||
|
const elapsed = ((Date.now() - startTime) / 1000).toFixed(2);
|
||||||
|
|
||||||
setMetadata(data);
|
setMetadata(data);
|
||||||
|
|
||||||
|
// Log detailed info based on type
|
||||||
|
if ("track" in data) {
|
||||||
|
logger.success(`fetched track: ${data.track.name} - ${data.track.artists}`);
|
||||||
|
logger.debug(`isrc: ${data.track.isrc}, duration: ${data.track.duration_ms}ms`);
|
||||||
|
} else if ("album_info" in data) {
|
||||||
|
logger.success(`fetched album: ${data.album_info.name}`);
|
||||||
|
logger.debug(`${data.track_list.length} tracks, released: ${data.album_info.release_date}`);
|
||||||
|
} else if ("playlist_info" in data) {
|
||||||
|
logger.success(`fetched playlist: ${data.track_list.length} tracks`);
|
||||||
|
logger.debug(`by ${data.playlist_info.owner.display_name || data.playlist_info.owner.name}`);
|
||||||
|
} else if ("artist_info" in data) {
|
||||||
|
logger.success(`fetched artist: ${data.artist_info.name}`);
|
||||||
|
logger.debug(`${data.album_list.length} albums, ${data.track_list.length} tracks`);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`fetch completed in ${elapsed}s`);
|
||||||
toast.success("Metadata fetched successfully");
|
toast.success("Metadata fetched successfully");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err instanceof Error ? err.message : "Failed to fetch metadata");
|
const errorMsg = err instanceof Error ? err.message : "Failed to fetch metadata";
|
||||||
|
logger.error(`fetch failed: ${errorMsg}`);
|
||||||
|
toast.error(errorMsg);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -33,6 +69,7 @@ export function useMetadata() {
|
|||||||
|
|
||||||
const handleFetchMetadata = async (url: string) => {
|
const handleFetchMetadata = async (url: string) => {
|
||||||
if (!url.trim()) {
|
if (!url.trim()) {
|
||||||
|
logger.warning("empty url provided");
|
||||||
toast.error("Please enter a Spotify URL");
|
toast.error("Please enter a Spotify URL");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -42,10 +79,13 @@ export function useMetadata() {
|
|||||||
|
|
||||||
if (isArtistUrl && !urlToFetch.includes("/discography")) {
|
if (isArtistUrl && !urlToFetch.includes("/discography")) {
|
||||||
urlToFetch = urlToFetch.replace(/\/$/, "") + "/discography/all";
|
urlToFetch = urlToFetch.replace(/\/$/, "") + "/discography/all";
|
||||||
|
logger.debug("converted to discography url");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isArtistUrl) {
|
if (isArtistUrl) {
|
||||||
|
logger.info("artist url detected, showing timeout dialog");
|
||||||
setPendingUrl(urlToFetch);
|
setPendingUrl(urlToFetch);
|
||||||
|
setPendingArtistName(null); // Clear artist name for URL input
|
||||||
setShowTimeoutDialog(true);
|
setShowTimeoutDialog(true);
|
||||||
} else {
|
} else {
|
||||||
await fetchMetadataDirectly(urlToFetch);
|
await fetchMetadataDirectly(urlToFetch);
|
||||||
@@ -56,15 +96,30 @@ export function useMetadata() {
|
|||||||
|
|
||||||
const handleConfirmFetch = async () => {
|
const handleConfirmFetch = async () => {
|
||||||
setShowTimeoutDialog(false);
|
setShowTimeoutDialog(false);
|
||||||
|
logger.info(`fetching artist discography (timeout: ${timeoutValue}s)...`);
|
||||||
|
logger.debug(`url: ${pendingUrl}`);
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setMetadata(null);
|
setMetadata(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const startTime = Date.now();
|
||||||
const data = await fetchSpotifyMetadata(pendingUrl, true, 1.0, timeoutValue);
|
const data = await fetchSpotifyMetadata(pendingUrl, true, 1.0, timeoutValue);
|
||||||
|
const elapsed = ((Date.now() - startTime) / 1000).toFixed(2);
|
||||||
|
|
||||||
setMetadata(data);
|
setMetadata(data);
|
||||||
|
|
||||||
|
if ("artist_info" in data) {
|
||||||
|
logger.success(`fetched artist: ${data.artist_info.name}`);
|
||||||
|
logger.debug(`${data.album_list.length} albums, ${data.track_list.length} tracks`);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`fetch completed in ${elapsed}s`);
|
||||||
toast.success("Metadata fetched successfully");
|
toast.success("Metadata fetched successfully");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err instanceof Error ? err.message : "Failed to fetch metadata");
|
const errorMsg = err instanceof Error ? err.message : "Failed to fetch metadata";
|
||||||
|
logger.error(`fetch failed: ${errorMsg}`);
|
||||||
|
toast.error(errorMsg);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -75,23 +130,54 @@ export function useMetadata() {
|
|||||||
name: string;
|
name: string;
|
||||||
external_urls: string;
|
external_urls: string;
|
||||||
}) => {
|
}) => {
|
||||||
|
logger.debug(`album clicked: ${album.name}`);
|
||||||
setSelectedAlbum(album);
|
setSelectedAlbum(album);
|
||||||
setShowAlbumDialog(true);
|
setShowAlbumDialog(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleArtistClick = async (artist: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
external_urls: string;
|
||||||
|
}) => {
|
||||||
|
logger.debug(`artist clicked: ${artist.name}`);
|
||||||
|
const artistUrl = artist.external_urls.replace(/\/$/, "") + "/discography/all";
|
||||||
|
setPendingUrl(artistUrl);
|
||||||
|
setPendingArtistName(artist.name);
|
||||||
|
setShowTimeoutDialog(true);
|
||||||
|
return artistUrl;
|
||||||
|
};
|
||||||
|
|
||||||
const handleConfirmAlbumFetch = async () => {
|
const handleConfirmAlbumFetch = async () => {
|
||||||
if (!selectedAlbum) return;
|
if (!selectedAlbum) return;
|
||||||
|
|
||||||
|
const albumUrl = selectedAlbum.external_urls;
|
||||||
|
logger.info(`fetching album: ${selectedAlbum.name}...`);
|
||||||
|
logger.debug(`url: ${albumUrl}`);
|
||||||
|
|
||||||
setShowAlbumDialog(false);
|
setShowAlbumDialog(false);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setMetadata(null);
|
setMetadata(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await fetchSpotifyMetadata(selectedAlbum.external_urls);
|
const startTime = Date.now();
|
||||||
|
const data = await fetchSpotifyMetadata(albumUrl);
|
||||||
|
const elapsed = ((Date.now() - startTime) / 1000).toFixed(2);
|
||||||
|
|
||||||
setMetadata(data);
|
setMetadata(data);
|
||||||
|
|
||||||
|
if ("album_info" in data) {
|
||||||
|
logger.success(`fetched album: ${data.album_info.name}`);
|
||||||
|
logger.debug(`${data.track_list.length} tracks, released: ${data.album_info.release_date}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`fetch completed in ${elapsed}s`);
|
||||||
toast.success("Album metadata fetched successfully");
|
toast.success("Album metadata fetched successfully");
|
||||||
|
return albumUrl;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err instanceof Error ? err.message : "Failed to fetch album metadata");
|
const errorMsg = err instanceof Error ? err.message : "Failed to fetch album metadata";
|
||||||
|
logger.error(`fetch failed: ${errorMsg}`);
|
||||||
|
toast.error(errorMsg);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setSelectedAlbum(null);
|
setSelectedAlbum(null);
|
||||||
@@ -108,9 +194,11 @@ export function useMetadata() {
|
|||||||
showAlbumDialog,
|
showAlbumDialog,
|
||||||
setShowAlbumDialog,
|
setShowAlbumDialog,
|
||||||
selectedAlbum,
|
selectedAlbum,
|
||||||
|
pendingArtistName,
|
||||||
handleFetchMetadata,
|
handleFetchMetadata,
|
||||||
handleConfirmFetch,
|
handleConfirmFetch,
|
||||||
handleAlbumClick,
|
handleAlbumClick,
|
||||||
handleConfirmAlbumFetch,
|
handleConfirmAlbumFetch,
|
||||||
|
handleArtistClick,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,10 +119,14 @@
|
|||||||
font-family: "Google Sans Flex", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
font-family: "Google Sans Flex", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
}
|
}
|
||||||
code, pre, .font-mono {
|
code, pre, .font-mono {
|
||||||
font-family: "Google Sans Code", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
font-family: "Google Sans Code", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--font-mono: "Google Sans Code", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||||
|
}
|
||||||
|
|
||||||
/* Sonner Toast Styling */
|
/* Sonner Toast Styling */
|
||||||
[data-sonner-toast] {
|
[data-sonner-toast] {
|
||||||
@apply rounded-lg shadow-lg border;
|
@apply rounded-lg shadow-lg border;
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
export type LogLevel = "info" | "success" | "warning" | "error" | "debug";
|
||||||
|
|
||||||
|
export interface LogEntry {
|
||||||
|
timestamp: Date;
|
||||||
|
level: LogLevel;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class Logger {
|
||||||
|
private logs: LogEntry[] = [];
|
||||||
|
private maxLogs = 500;
|
||||||
|
private listeners: Set<() => void> = new Set();
|
||||||
|
|
||||||
|
private addLog(level: LogLevel, message: string) {
|
||||||
|
const entry: LogEntry = {
|
||||||
|
timestamp: new Date(),
|
||||||
|
level,
|
||||||
|
message: message.toLowerCase(),
|
||||||
|
};
|
||||||
|
this.logs.push(entry);
|
||||||
|
if (this.logs.length > this.maxLogs) {
|
||||||
|
this.logs.shift();
|
||||||
|
}
|
||||||
|
this.notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
info(message: string) {
|
||||||
|
this.addLog("info", message);
|
||||||
|
}
|
||||||
|
|
||||||
|
success(message: string) {
|
||||||
|
this.addLog("success", message);
|
||||||
|
}
|
||||||
|
|
||||||
|
warning(message: string) {
|
||||||
|
this.addLog("warning", message);
|
||||||
|
}
|
||||||
|
|
||||||
|
error(message: string) {
|
||||||
|
this.addLog("error", message);
|
||||||
|
}
|
||||||
|
|
||||||
|
debug(message: string) {
|
||||||
|
this.addLog("debug", message);
|
||||||
|
}
|
||||||
|
|
||||||
|
getLogs(): LogEntry[] {
|
||||||
|
return [...this.logs];
|
||||||
|
}
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
this.logs = [];
|
||||||
|
this.notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe(listener: () => void) {
|
||||||
|
this.listeners.add(listener);
|
||||||
|
return () => this.listeners.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
private notifyListeners() {
|
||||||
|
this.listeners.forEach((listener) => listener());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const logger = new Logger();
|
||||||
@@ -1,31 +1,51 @@
|
|||||||
import { toast } from 'sonner';
|
import { toast } from "sonner";
|
||||||
import { playSuccessSound, playErrorSound, playWarningSound, playInfoSound } from './audio';
|
import {
|
||||||
|
playSuccessSound,
|
||||||
|
playErrorSound,
|
||||||
|
playWarningSound,
|
||||||
|
playInfoSound,
|
||||||
|
} from "./audio";
|
||||||
|
import { logger } from "./logger";
|
||||||
|
|
||||||
|
const toastStyle = {
|
||||||
|
className: "font-mono lowercase",
|
||||||
|
};
|
||||||
|
|
||||||
// Wrapper functions for toast with sound effects
|
// Wrapper functions for toast with sound effects
|
||||||
export const toastWithSound = {
|
export const toastWithSound = {
|
||||||
success: (message: string, data?: any) => {
|
success: (message: string, data?: any) => {
|
||||||
|
const msg = message.toLowerCase();
|
||||||
|
logger.success(msg);
|
||||||
playSuccessSound();
|
playSuccessSound();
|
||||||
return toast.success(message, data);
|
return toast.success(msg, { ...toastStyle, ...data });
|
||||||
},
|
},
|
||||||
|
|
||||||
error: (message: string, data?: any) => {
|
error: (message: string, data?: any) => {
|
||||||
|
const msg = message.toLowerCase();
|
||||||
|
logger.error(msg);
|
||||||
playErrorSound();
|
playErrorSound();
|
||||||
return toast.error(message, data);
|
return toast.error(msg, { ...toastStyle, ...data });
|
||||||
},
|
},
|
||||||
|
|
||||||
warning: (message: string, data?: any) => {
|
warning: (message: string, data?: any) => {
|
||||||
|
const msg = message.toLowerCase();
|
||||||
|
logger.warning(msg);
|
||||||
playWarningSound();
|
playWarningSound();
|
||||||
return toast.warning(message, data);
|
return toast.warning(msg, { ...toastStyle, ...data });
|
||||||
},
|
},
|
||||||
|
|
||||||
info: (message: string, data?: any) => {
|
info: (message: string, data?: any) => {
|
||||||
|
const msg = message.toLowerCase();
|
||||||
|
logger.info(msg);
|
||||||
playInfoSound();
|
playInfoSound();
|
||||||
return toast.info(message, data);
|
return toast.info(msg, { ...toastStyle, ...data });
|
||||||
},
|
},
|
||||||
|
|
||||||
// Default toast without specific type
|
// Default toast without specific type
|
||||||
message: (message: string, data?: any) => {
|
message: (message: string, data?: any) => {
|
||||||
|
const msg = message.toLowerCase();
|
||||||
|
logger.info(msg);
|
||||||
playInfoSound();
|
playInfoSound();
|
||||||
return toast(message, data);
|
return toast(msg, { ...toastStyle, ...data });
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
+12
-8
@@ -1,12 +1,16 @@
|
|||||||
import { StrictMode } from 'react'
|
import { StrictMode } from "react";
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from "react-dom/client";
|
||||||
import './index.css'
|
import "./index.css";
|
||||||
import App from './App.tsx'
|
import App from "./App.tsx";
|
||||||
import { Toaster } from '@/components/ui/sonner'
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
|
import { DebugLogger } from "@/components/DebugLogger";
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<App />
|
||||||
<Toaster position="bottom-left" duration={1000} />
|
<Toaster position="bottom-left" duration={1000} />
|
||||||
</StrictMode>,
|
<div className="fixed bottom-2 left-2 z-50">
|
||||||
)
|
<DebugLogger />
|
||||||
|
</div>
|
||||||
|
</StrictMode>
|
||||||
|
);
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
export interface ArtistSimple {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
external_urls: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TrackMetadata {
|
export interface TrackMetadata {
|
||||||
artists: string;
|
artists: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -10,6 +16,11 @@ export interface TrackMetadata {
|
|||||||
isrc: string;
|
isrc: string;
|
||||||
album_type?: string;
|
album_type?: string;
|
||||||
spotify_id?: string;
|
spotify_id?: string;
|
||||||
|
album_id?: string;
|
||||||
|
album_url?: string;
|
||||||
|
artist_id?: string;
|
||||||
|
artist_url?: string;
|
||||||
|
artists_data?: ArtistSimple[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TrackResponse {
|
export interface TrackResponse {
|
||||||
|
|||||||
+1
-1
@@ -11,7 +11,7 @@
|
|||||||
},
|
},
|
||||||
"info": {
|
"info": {
|
||||||
"productName": "SpotiFLAC",
|
"productName": "SpotiFLAC",
|
||||||
"productVersion": "6.1"
|
"productVersion": "6.2"
|
||||||
},
|
},
|
||||||
"wailsjsdir": "./frontend",
|
"wailsjsdir": "./frontend",
|
||||||
"assetdir": "./frontend/dist",
|
"assetdir": "./frontend/dist",
|
||||||
|
|||||||
Reference in New Issue
Block a user