This commit is contained in:
afkarxyz
2026-03-25 20:44:31 +07:00
parent f8ef1180f6
commit 5ebd28982b
13 changed files with 423 additions and 26 deletions
+41
View File
@@ -106,6 +106,22 @@ type DownloadResponse struct {
ItemID string `json:"item_id,omitempty"`
}
func cleanupInvalidDownloadArtifacts(paths ...string) {
seen := make(map[string]struct{}, len(paths))
for _, path := range paths {
if path == "" {
continue
}
if _, ok := seen[path]; ok {
continue
}
seen[path] = struct{}{}
if err := os.Remove(path); err == nil {
fmt.Printf("Removed invalid download artifact: %s\n", path)
}
}
}
func (a *App) GetStreamingURLs(spotifyTrackID string, region string) (string, error) {
if spotifyTrackID == "" {
return "", fmt.Errorf("spotify track ID is required")
@@ -474,6 +490,23 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
filename = strings.TrimPrefix(filename, "EXISTS:")
}
if !alreadyExists {
validated, validationErr := backend.ValidateDownloadedTrackDuration(filename, req.Duration)
if validationErr != nil {
cleanupInvalidDownloadArtifacts(filename)
errorMessage := validationErr.Error()
backend.FailDownloadItem(itemID, errorMessage)
return DownloadResponse{
Success: false,
Error: errorMessage,
ItemID: itemID,
}, fmt.Errorf(errorMessage)
}
if !validated {
fmt.Printf("[DownloadValidation] Skipped duration validation for %s (expected=%ds)\n", filename, req.Duration)
}
}
if !alreadyExists && req.SpotifyID != "" && req.EmbedLyrics && (strings.HasSuffix(filename, ".flac") || strings.HasSuffix(filename, ".mp3") || strings.HasSuffix(filename, ".m4a")) {
fmt.Printf("\nWaiting for lyrics fetch to complete...\n")
lyrics := <-lyricsChan
@@ -505,6 +538,14 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
message = "File already exists"
backend.SkipDownloadItem(itemID, filename)
} else {
if strings.EqualFold(filepath.Ext(filename), ".flac") && req.CoverURL != "" {
coverClient := backend.NewCoverClient()
if iconErr := coverClient.ApplyMacOSFLACFileIcon(filename, req.CoverURL, 256, req.EmbedMaxQualityCover); iconErr != nil {
fmt.Printf("Warning: failed to set macOS FLAC file icon: %v\n", iconErr)
} else {
fmt.Printf("macOS FLAC file icon set: %s\n", filename)
}
}
if fileInfo, statErr := os.Stat(filename); statErr == nil {
finalSize := float64(fileInfo.Size()) / (1024 * 1024)
+69
View File
@@ -1,7 +1,10 @@
package backend
import (
"bytes"
"fmt"
"image"
"image/png"
"io"
"net/http"
"os"
@@ -9,6 +12,9 @@ import (
"regexp"
"strings"
"time"
xdraw "golang.org/x/image/draw"
_ "image/jpeg"
)
const (
@@ -170,6 +176,69 @@ func (c *CoverClient) DownloadCoverToPath(coverURL, outputPath string, embedMaxQ
return nil
}
func (c *CoverClient) ApplyMacOSFLACFileIcon(filePath, coverURL string, iconSize int, embedMaxQualityCover bool) error {
if filePath == "" {
return fmt.Errorf("file path is required")
}
if coverURL == "" {
return fmt.Errorf("cover URL is required")
}
tmpFile, err := os.CreateTemp("", "spotiflac-file-icon-*.jpg")
if err != nil {
return fmt.Errorf("failed to create temporary cover file: %w", err)
}
tmpPath := tmpFile.Name()
tmpFile.Close()
defer os.Remove(tmpPath)
if err := c.DownloadCoverToPath(coverURL, tmpPath, embedMaxQualityCover); err != nil {
return err
}
return SetMacOSFileIconFromImage(filePath, tmpPath, iconSize)
}
func ResizeImageForIcon(sourcePath string, iconSize int) (string, error) {
if sourcePath == "" {
return "", fmt.Errorf("source image path is required")
}
if iconSize <= 0 {
iconSize = 256
}
in, err := os.Open(sourcePath)
if err != nil {
return "", fmt.Errorf("failed to open source image: %w", err)
}
defer in.Close()
srcImage, _, err := image.Decode(in)
if err != nil {
return "", fmt.Errorf("failed to decode source image: %w", err)
}
dst := image.NewRGBA(image.Rect(0, 0, iconSize, iconSize))
xdraw.CatmullRom.Scale(dst, dst.Bounds(), srcImage, srcImage.Bounds(), xdraw.Over, nil)
tmpFile, err := os.CreateTemp("", "spotiflac-resized-icon-*.png")
if err != nil {
return "", fmt.Errorf("failed to create resized icon temp file: %w", err)
}
tmpPath := tmpFile.Name()
defer tmpFile.Close()
var encoded bytes.Buffer
if err := png.Encode(&encoded, dst); err != nil {
return "", fmt.Errorf("failed to encode resized icon image: %w", err)
}
if _, err := io.Copy(tmpFile, &encoded); err != nil {
return "", fmt.Errorf("failed to write resized icon image: %w", err)
}
return tmpPath, nil
}
func (c *CoverClient) DownloadCover(req CoverDownloadRequest) (*CoverDownloadResponse, error) {
if req.CoverURL == "" {
return &CoverDownloadResponse{
+44
View File
@@ -0,0 +1,44 @@
package backend
import (
"fmt"
"math"
)
const (
previewMaxSeconds = 35
previewExpectedMinSeconds = 60
largeMismatchMinExpected = 90
minAllowedDurationDiff = 15
durationDiffRatio = 0.25
)
func ValidateDownloadedTrackDuration(filePath string, expectedSeconds int) (bool, error) {
if filePath == "" || expectedSeconds <= 0 {
return false, nil
}
actualDuration, err := GetAudioDuration(filePath)
if err != nil || actualDuration <= 0 {
return false, nil
}
actualSeconds := int(math.Round(actualDuration))
if actualSeconds <= 0 {
return false, nil
}
if expectedSeconds >= previewExpectedMinSeconds && actualSeconds <= previewMaxSeconds {
return true, fmt.Errorf("detected preview/sample download: file is %ds, expected about %ds. file was removed", actualSeconds, expectedSeconds)
}
if expectedSeconds >= largeMismatchMinExpected {
allowedDiff := int(math.Max(minAllowedDurationDiff, math.Round(float64(expectedSeconds)*durationDiffRatio)))
diff := int(math.Abs(float64(actualSeconds - expectedSeconds)))
if diff > allowedDiff {
return true, fmt.Errorf("downloaded file duration mismatch: file is %ds, expected about %ds. file was removed", actualSeconds, expectedSeconds)
}
}
return true, nil
}
+45
View File
@@ -0,0 +1,45 @@
//go:build darwin
package backend
import (
"fmt"
"os"
"os/exec"
"strings"
)
func SetMacOSFileIconFromImage(filePath, imagePath string, iconSize int) error {
if filePath == "" {
return fmt.Errorf("file path is required")
}
if imagePath == "" {
return fmt.Errorf("image path is required")
}
resizedPath, err := ResizeImageForIcon(imagePath, iconSize)
if err != nil {
return err
}
defer os.Remove(resizedPath)
script := `
use framework "AppKit"
on run argv
set imagePath to item 1 of argv
set targetPath to item 2 of argv
set iconImage to current application's NSImage's alloc()'s initWithContentsOfFile:imagePath
if iconImage is missing value then error "Failed to load icon image"
set didSet to (current application's NSWorkspace's sharedWorkspace()'s setIcon:iconImage forFile:targetPath options:0) as boolean
if didSet is false then error "Failed to set custom file icon"
end run
`
cmd := exec.Command("osascript", "-", resizedPath, filePath)
cmd.Stdin = strings.NewReader(script)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("failed to apply macOS file icon: %v (%s)", err, strings.TrimSpace(string(output)))
}
return nil
}
+7
View File
@@ -0,0 +1,7 @@
//go:build !darwin
package backend
func SetMacOSFileIconFromImage(filePath, imagePath string, iconSize int) error {
return nil
}
+60 -12
View File
@@ -11,6 +11,34 @@ import (
"time"
)
func streamTrackListChunks(ctx context.Context, tracks []AlbumTrackMetadata, callback MetadataCallback) error {
if callback == nil || len(tracks) == 0 {
return nil
}
const chunkSize = 25
for start := 0; start < len(tracks); start += chunkSize {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
end := start + chunkSize
if end > len(tracks) {
end = len(tracks)
}
callback(tracks[start:end])
if end < len(tracks) {
time.Sleep(15 * time.Millisecond)
}
}
return nil
}
func GetSpotifyDataWithAPI(ctx context.Context, spotifyURL string, useAPI bool, apiBaseURL string, batch bool, delay time.Duration, separator string, callback MetadataCallback) (interface{}, error) {
if !useAPI || apiBaseURL == "" {
return GetFilteredSpotifyData(ctx, spotifyURL, batch, delay, separator, callback)
@@ -21,6 +49,10 @@ func GetSpotifyDataWithAPI(ctx context.Context, spotifyURL string, useAPI bool,
return nil, fmt.Errorf("invalid Spotify URL: %s", spotifyURL)
}
if spotifyType == "artist" {
return GetFilteredSpotifyData(ctx, spotifyURL, batch, delay, separator, callback)
}
apiURL := fmt.Sprintf("%s/%s/%s", strings.TrimSuffix(apiBaseURL, "/"), spotifyType, id)
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
@@ -62,36 +94,52 @@ func GetSpotifyDataWithAPI(ctx context.Context, spotifyURL string, useAPI bool,
return nil, fmt.Errorf("failed to decode album response: %w", err)
}
data = &albumResp
if callback != nil {
callback(&AlbumResponsePayload{
AlbumInfo: albumResp.AlbumInfo,
TrackList: []AlbumTrackMetadata{},
})
if err := streamTrackListChunks(ctx, albumResp.TrackList, callback); err != nil {
return nil, err
}
}
case "playlist":
var playlistResp PlaylistResponsePayload
if err := json.Unmarshal(bodyBytes, &playlistResp); err != nil {
return nil, fmt.Errorf("failed to decode playlist response: %w", err)
}
data = playlistResp
if callback != nil {
callback(PlaylistResponsePayload{
PlaylistInfo: playlistResp.PlaylistInfo,
TrackList: []AlbumTrackMetadata{},
})
if err := streamTrackListChunks(ctx, playlistResp.TrackList, callback); err != nil {
return nil, err
}
}
case "artist":
var artistResp ArtistDiscographyPayload
if err := json.Unmarshal(bodyBytes, &artistResp); err != nil {
return nil, fmt.Errorf("failed to decode artist response: %w", err)
}
data = &artistResp
if callback != nil {
callback(&ArtistDiscographyPayload{
ArtistInfo: artistResp.ArtistInfo,
AlbumList: artistResp.AlbumList,
TrackList: []AlbumTrackMetadata{},
})
if err := streamTrackListChunks(ctx, artistResp.TrackList, callback); err != nil {
return nil, err
}
}
default:
return nil, fmt.Errorf("unsupported Spotify type: %s", spotifyType)
}
if callback != nil {
switch payload := data.(type) {
case *AlbumResponsePayload:
if len(payload.TrackList) > 0 {
callback(payload.TrackList)
}
case PlaylistResponsePayload:
if len(payload.TrackList) > 0 {
callback(payload.TrackList)
}
case *ArtistDiscographyPayload:
if len(payload.TrackList) > 0 {
callback(payload.TrackList)
}
case TrackResponse:
t := payload.Track
callback([]AlbumTrackMetadata{{
+91 -2
View File
@@ -36,6 +36,80 @@ import { useDownloadQueueDialog } from "@/hooks/useDownloadQueueDialog";
import { useDownloadProgress } from "@/hooks/useDownloadProgress";
const HISTORY_KEY = "spotiflac_fetch_history";
const MAX_HISTORY = 5;
function extractSpotifyEntityFromURL(url: string): { type: string; id: string; } | null {
const trimmed = url.trim();
if (!trimmed) {
return null;
}
const spotifyUriMatch = trimmed.match(/^spotify:(track|album|playlist|artist):([A-Za-z0-9]+)$/i);
if (spotifyUriMatch) {
return {
type: spotifyUriMatch[1].toLowerCase(),
id: spotifyUriMatch[2],
};
}
try {
const parsed = new URL(trimmed);
const segments = parsed.pathname.split("/").filter(Boolean);
const supportedTypes = new Set(["track", "album", "playlist", "artist"]);
for (let i = 0; i < segments.length - 1; i++) {
const segment = segments[i].toLowerCase();
if (!supportedTypes.has(segment)) {
continue;
}
const id = segments[i + 1];
if (id) {
return { type: segment, id };
}
}
}
catch {
}
return null;
}
function normalizeHistoryURL(url: string): string {
const trimmed = url.trim();
if (!trimmed)
return trimmed;
const withoutQuery = trimmed.split("?")[0].replace(/\/+$/, "");
const spotifyEntity = extractSpotifyEntityFromURL(withoutQuery);
if (spotifyEntity) {
return `https://open.spotify.com/${spotifyEntity.type}/${spotifyEntity.id}`;
}
return withoutQuery.replace(/(\/artist\/[A-Za-z0-9]+)\/discography\/all$/i, "$1");
}
function getHistoryIdentityKey(type: HistoryItem["type"], url: string): string {
const normalizedUrl = normalizeHistoryURL(url);
const spotifyEntity = extractSpotifyEntityFromURL(normalizedUrl);
if (spotifyEntity) {
return `${type}:${spotifyEntity.id}`;
}
return `${type}:${normalizedUrl}`;
}
function dedupeHistoryItems(items: HistoryItem[]): HistoryItem[] {
const seen = new Set<string>();
const deduped: HistoryItem[] = [];
for (const item of items) {
const normalizedUrl = normalizeHistoryURL(item.url);
const key = getHistoryIdentityKey(item.type, normalizedUrl);
if (seen.has(key))
continue;
seen.add(key);
deduped.push({ ...item, url: normalizedUrl });
}
return deduped;
}
function App() {
const [currentPage, setCurrentPage] = useState<PageType>("main");
const [spotifyUrl, setSpotifyUrl] = useState("");
@@ -167,7 +241,9 @@ function App() {
try {
const saved = localStorage.getItem(HISTORY_KEY);
if (saved) {
setFetchHistory(JSON.parse(saved));
const deduped = dedupeHistoryItems(JSON.parse(saved));
setFetchHistory(deduped);
localStorage.setItem(HISTORY_KEY, JSON.stringify(deduped));
}
}
catch (err) {
@@ -222,9 +298,12 @@ function App() {
};
const addToHistory = (item: Omit<HistoryItem, "id" | "timestamp">) => {
setFetchHistory((prev) => {
const filtered = prev.filter((h) => h.url !== item.url);
const normalizedUrl = normalizeHistoryURL(item.url);
const identityKey = getHistoryIdentityKey(item.type, normalizedUrl);
const filtered = prev.filter((h) => getHistoryIdentityKey(h.type, h.url) !== identityKey);
const newItem: HistoryItem = {
...item,
url: normalizedUrl,
id: crypto.randomUUID(),
timestamp: Date.now(),
};
@@ -345,6 +424,8 @@ function App() {
if ("album_info" in metadata.metadata) {
const { album_info, track_list } = metadata.metadata;
return (<AlbumInfo albumInfo={album_info} trackList={track_list} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={download.downloadedTracks} failedTracks={download.failedTracks} skippedTracks={download.skippedTracks} downloadingTrack={download.downloadingTrack} isDownloading={download.isDownloading} bulkDownloadType={download.bulkDownloadType} downloadProgress={download.downloadProgress} currentDownloadInfo={download.currentDownloadInfo} currentPage={currentListPage} itemsPerPage={ITEMS_PER_PAGE} downloadedLyrics={lyrics.downloadedLyrics} failedLyrics={lyrics.failedLyrics} skippedLyrics={lyrics.skippedLyrics} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} checkingAvailabilityTrack={availability.checkingTrackId} availabilityMap={availability.availabilityMap} downloadedCovers={cover.downloadedCovers} failedCovers={cover.failedCovers} skippedCovers={cover.skippedCovers} downloadingCoverTrack={cover.downloadingCoverTrack} isBulkDownloadingCovers={cover.isBulkDownloadingCovers} isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics} onSearchChange={handleSearchChange} onSortChange={setSortBy} onToggleTrack={toggleTrackSelection} onToggleSelectAll={toggleSelectAll} onDownloadTrack={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, album_info.name, position, albumArtist, releaseDate, discNumber, true)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, album_info.name, position, trackId, albumArtist, releaseDate, discNumber, true)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, album_info.name, undefined, true)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, album_info.name, true)} onDownloadAll={() => download.handleDownloadAll(track_list, album_info.name, true)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, album_info.name, true)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onPageChange={setCurrentListPage} onBack={metadata.resetMetadata} onArtistClick={async (artist) => {
const pendingArtistUrl = artist.external_urls.replace(/\/$/, "") + "/discography/all";
setSpotifyUrl(pendingArtistUrl);
const artistUrl = await metadata.handleArtistClick(artist);
if (artistUrl) {
setSpotifyUrl(artistUrl);
@@ -359,6 +440,8 @@ function App() {
if ("playlist_info" in metadata.metadata) {
const { playlist_info, track_list } = metadata.metadata;
return (<PlaylistInfo playlistInfo={playlist_info} trackList={track_list} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={download.downloadedTracks} failedTracks={download.failedTracks} skippedTracks={download.skippedTracks} downloadingTrack={download.downloadingTrack} isDownloading={download.isDownloading} bulkDownloadType={download.bulkDownloadType} downloadProgress={download.downloadProgress} currentDownloadInfo={download.currentDownloadInfo} currentPage={currentListPage} itemsPerPage={ITEMS_PER_PAGE} downloadedLyrics={lyrics.downloadedLyrics} failedLyrics={lyrics.failedLyrics} skippedLyrics={lyrics.skippedLyrics} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} checkingAvailabilityTrack={availability.checkingTrackId} availabilityMap={availability.availabilityMap} downloadedCovers={cover.downloadedCovers} failedCovers={cover.failedCovers} skippedCovers={cover.skippedCovers} downloadingCoverTrack={cover.downloadingCoverTrack} isBulkDownloadingCovers={cover.isBulkDownloadingCovers} isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics} onSearchChange={handleSearchChange} onSortChange={setSortBy} onToggleTrack={toggleTrackSelection} onToggleSelectAll={toggleSelectAll} onDownloadTrack={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, playlist_info.owner.name, position, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, playlist_info.owner.name, position, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, playlist_info.owner.name)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, playlist_info.owner.name)} onDownloadAll={() => download.handleDownloadAll(track_list, playlist_info.owner.name)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, playlist_info.owner.name)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onPageChange={setCurrentListPage} onBack={metadata.resetMetadata} onAlbumClick={metadata.handleAlbumClick} onArtistClick={async (artist) => {
const pendingArtistUrl = artist.external_urls.replace(/\/$/, "") + "/discography/all";
setSpotifyUrl(pendingArtistUrl);
const artistUrl = await metadata.handleArtistClick(artist);
if (artistUrl) {
setSpotifyUrl(artistUrl);
@@ -373,6 +456,8 @@ function App() {
if ("artist_info" in metadata.metadata) {
const { artist_info, album_list, track_list } = metadata.metadata;
return (<ArtistInfo artistInfo={artist_info} albumList={album_list} trackList={track_list} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={download.downloadedTracks} failedTracks={download.failedTracks} skippedTracks={download.skippedTracks} downloadingTrack={download.downloadingTrack} isDownloading={download.isDownloading} bulkDownloadType={download.bulkDownloadType} downloadProgress={download.downloadProgress} currentDownloadInfo={download.currentDownloadInfo} currentPage={currentListPage} itemsPerPage={ITEMS_PER_PAGE} downloadedLyrics={lyrics.downloadedLyrics} failedLyrics={lyrics.failedLyrics} skippedLyrics={lyrics.skippedLyrics} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} checkingAvailabilityTrack={availability.checkingTrackId} availabilityMap={availability.availabilityMap} downloadedCovers={cover.downloadedCovers} failedCovers={cover.failedCovers} skippedCovers={cover.skippedCovers} downloadingCoverTrack={cover.downloadingCoverTrack} isBulkDownloadingCovers={cover.isBulkDownloadingCovers} isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics} onSearchChange={handleSearchChange} onSortChange={setSortBy} onToggleTrack={toggleTrackSelection} onToggleSelectAll={toggleSelectAll} onDownloadTrack={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, artist_info.name, position, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, artist_info.name, position, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, artist_info.name)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, artist_info.name)} onDownloadAll={() => download.handleDownloadAll(track_list, artist_info.name)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, artist_info.name)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onAlbumClick={metadata.handleAlbumClick} onBack={metadata.resetMetadata} onArtistClick={async (artist) => {
const pendingArtistUrl = artist.external_urls.replace(/\/$/, "") + "/discography/all";
setSpotifyUrl(pendingArtistUrl);
const artistUrl = await metadata.handleArtistClick(artist);
if (artistUrl) {
setSpotifyUrl(artistUrl);
@@ -459,6 +544,10 @@ function App() {
Cancel
</Button>
<Button onClick={async () => {
const pendingAlbumUrl = metadata.selectedAlbum?.external_urls;
if (pendingAlbumUrl) {
setSpotifyUrl(pendingAlbumUrl);
}
const albumUrl = await metadata.handleConfirmAlbumFetch();
if (albumUrl) {
setSpotifyUrl(albumUrl);
+3 -3
View File
@@ -122,7 +122,7 @@ export function AlbumInfo({ albumInfo, trackList, searchQuery, sortBy, selectedT
if (response.already_exists)
toast.info("Cover already exists");
else
toast.success("Album cover downloaded");
toast.success("Separate album cover downloaded");
}
else {
toast.error(response.error || "Failed to download cover");
@@ -153,7 +153,7 @@ export function AlbumInfo({ albumInfo, trackList, searchQuery, sortBy, selectedT
{downloadingAlbumCover ? <Spinner /> : <ImageDown className="h-4 w-4"/>}
</Button>
</TooltipTrigger>
<TooltipContent><p>Download Album Cover</p></TooltipContent>
<TooltipContent><p>Download Separate Album Cover</p></TooltipContent>
</Tooltip>
</div>
</div>)}
@@ -203,7 +203,7 @@ export function AlbumInfo({ albumInfo, trackList, searchQuery, sortBy, selectedT
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download All Covers</p>
<p>Download All Separate Covers</p>
</TooltipContent>
</Tooltip>)}
{downloadedTracks.size > 0 && (<Button onClick={onOpenFolder} variant="outline">
+45 -2
View File
@@ -100,7 +100,31 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
const [downloadingGalleryIndex, setDownloadingGalleryIndex] = useState<number | null>(null);
const [downloadingAllGallery, setDownloadingAllGallery] = useState(false);
const [activeTab, setActiveTab] = useState<"albums" | "tracks" | "gallery">("albums");
const [activeAlbumFilter, setActiveAlbumFilter] = useState<string>("all");
const displayedAlbumCount = artistInfo.total_albums || albumList.length;
const albumFilterCounts = useMemo(() => {
const counts = new Map<string, number>();
counts.set("all", (albumList || []).length);
for (const album of albumList || []) {
const type = (album.album_type || "").trim().toLowerCase();
if (!type)
continue;
counts.set(type, (counts.get(type) || 0) + 1);
}
return counts;
}, [albumList]);
const albumFilters = useMemo(() => {
const uniqueTypes = Array.from(new Set((albumList || [])
.map((album) => (album.album_type || "").trim().toLowerCase())
.filter(Boolean)));
return ["all", ...uniqueTypes];
}, [albumList]);
const filteredAlbums = useMemo(() => {
if (activeAlbumFilter === "all") {
return albumList || [];
}
return (albumList || []).filter((album) => (album.album_type || "").trim().toLowerCase() === activeAlbumFilter);
}, [albumList, activeAlbumFilter]);
const filteredAlbumGroups = useMemo(() => {
const albumTypeMap = new Map(albumList.map(a => [a.name, a.album_type]));
const albumGroups = trackList.reduce((acc, track) => {
@@ -127,6 +151,17 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
return dateB.localeCompare(dateA);
});
}, [trackList, albumList]);
const formatAlbumFilterLabel = (value: string) => {
const count = albumFilterCounts.get(value) || 0;
if (value === "all")
return `All (${count})`;
const label = value
.split(/[_\s]+/)
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
return `${label} (${count})`;
};
const handleDownloadHeader = async () => {
if (!artistInfo.header)
return;
@@ -461,8 +496,13 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
</Button>)}
</div>
</div>
{albumFilters.length > 1 && (<div className="flex flex-wrap gap-2">
{albumFilters.map((filter) => (<Button key={filter} size="sm" variant={activeAlbumFilter === filter ? "default" : "outline"} onClick={() => setActiveAlbumFilter(filter)}>
{formatAlbumFilterLabel(filter)}
</Button>))}
</div>)}
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{albumList.map((album) => {
{filteredAlbums.map((album) => {
const albumTracks = trackList.filter(t => t.album_name === album.name);
const tracksWithId = albumTracks.filter(t => t.spotify_id);
const isSelected = tracksWithId.length > 0 && tracksWithId.every(t => selectedTracks.includes(t.spotify_id!));
@@ -495,6 +535,9 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
</div>);
})}
</div>
{filteredAlbums.length === 0 && (<div className="rounded-lg border border-dashed border-border p-6 text-sm text-muted-foreground">
No releases found for the selected discography filter.
</div>)}
</div>)}
{activeTab === "tracks" && trackList.length > 0 && (<div className="space-y-4">
@@ -564,7 +607,7 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download All Covers</p>
<p>Download All Separate Covers</p>
</TooltipContent>
</Tooltip>)}
{downloadedTracks.size > 0 && (<Button onClick={onOpenFolder} size="sm" variant="outline">
@@ -13,18 +13,18 @@ export function DownloadProgressToast({ onClick }: DownloadProgressToastProps) {
return null;
}
return (<div className="fixed bottom-4 left-[calc(56px+1rem)] z-50 animate-in slide-in-from-bottom-5 data-[state=closed]:animate-out data-[state=closed]:slide-out-to-bottom-5">
<Button variant="outline" className="bg-background border rounded-lg shadow-lg p-3 h-auto hover:bg-muted/50 transition-colors cursor-pointer" onClick={onClick}>
<Button variant="outline" className="h-auto cursor-pointer rounded-lg border-border bg-background p-3 text-foreground shadow-lg transition-colors hover:bg-muted dark:border-blue-800 dark:bg-blue-950 dark:text-blue-100 dark:hover:bg-blue-900" onClick={onClick}>
<div className="flex items-center gap-3">
<Download className={`h-4 w-4 text-primary ${progress.is_downloading ? 'animate-bounce' : ''}`}/>
<Download className={`h-4 w-4 text-blue-600 dark:text-blue-400 ${progress.is_downloading ? 'animate-bounce' : ''}`}/>
<div className="flex flex-col min-w-[80px]">
<p className="text-sm font-medium font-mono tabular-nums">
{progress.mb_downloaded.toFixed(2)} MB
</p>
{progress.speed_mbps > 0 && (<p className="text-xs text-muted-foreground font-mono tabular-nums">
{progress.speed_mbps > 0 && (<p className="text-xs font-mono tabular-nums text-muted-foreground dark:text-blue-300">
{progress.speed_mbps.toFixed(2)} MB/s
</p>)}
</div>
<ChevronRight className="h-4 w-4 text-muted-foreground ml-1"/>
<ChevronRight className="ml-1 h-4 w-4 text-muted-foreground dark:text-blue-300"/>
</div>
</Button>
</div>);
+3 -3
View File
@@ -134,7 +134,7 @@ export function PlaylistInfo({ playlistInfo, trackList, searchQuery, sortBy, sel
if (response.already_exists)
toast.info("Cover already exists");
else
toast.success("Playlist cover downloaded");
toast.success("Separate playlist cover downloaded");
}
else {
toast.error(response.error || "Failed to download cover");
@@ -165,7 +165,7 @@ export function PlaylistInfo({ playlistInfo, trackList, searchQuery, sortBy, sel
{downloadingPlaylistCover ? <Spinner /> : <ImageDown className="h-4 w-4"/>}
</Button>
</TooltipTrigger>
<TooltipContent><p>Download Playlist Cover</p></TooltipContent>
<TooltipContent><p>Download Separate Playlist Cover</p></TooltipContent>
</Tooltip>
</div>
</div>)}
@@ -213,7 +213,7 @@ export function PlaylistInfo({ playlistInfo, trackList, searchQuery, sortBy, sel
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download All Covers</p>
<p>Download All Separate Covers</p>
</TooltipContent>
</Tooltip>)}
{downloadedTracks.size > 0 && (<Button onClick={onOpenFolder} variant="outline">
+1
View File
@@ -11,6 +11,7 @@ require (
github.com/ulikunitz/xz v0.5.15
github.com/wailsapp/wails/v2 v2.11.0
go.etcd.io/bbolt v1.4.3
golang.org/x/image v0.12.0
golang.org/x/text v0.31.0
)
+10
View File
@@ -82,15 +82,20 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/image v0.12.0 h1:w13vZbU4o5rKOFFR8y7M+c4A5jXDC0uXTdHYRP8X2DQ=
golang.org/x/image v0.12.0/go.mod h1:Lu90jvHG7GfemOIcldsh9A2hS01ocl6oNO7ype5mEnk=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -101,21 +106,26 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=