From 2fc08de75787603a997a97b66d63d44d3d4e9045 Mon Sep 17 00:00:00 2001 From: afkarxyz Date: Wed, 14 Jan 2026 06:28:51 +0700 Subject: [PATCH] v7.0.5 --- app.go | 24 ++- backend/qobuz.go | 43 +++- frontend/eslint.config.js | 41 ++-- frontend/scripts/generate-icon.js | 35 ++-- frontend/src/components/DownloadQueue.tsx | 238 ++++++++++++---------- frontend/src/components/SettingsPage.tsx | 92 ++++++++- frontend/src/lib/settings.ts | 8 +- frontend/vite.config.ts | 22 +- 8 files changed, 318 insertions(+), 185 deletions(-) diff --git a/app.go b/app.go index bc738ca..97f33f2 100644 --- a/app.go +++ b/app.go @@ -7,11 +7,20 @@ import ( "fmt" "os" "path/filepath" + "regexp" "spotiflac/backend" "strings" "time" + + "github.com/wailsapp/wails/v2/pkg/runtime" ) +var isrcRegex = regexp.MustCompile(`^[A-Z]{2}[A-Z0-9]{3}\d{2}\d{5}$`) + +func isValidISRC(isrc string) bool { + return isrcRegex.MatchString(isrc) +} + type App struct { ctx context.Context } @@ -336,6 +345,11 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) { } deezerISRC := req.ISRC + + if len(deezerISRC) != 12 || !isValidISRC(deezerISRC) { + deezerISRC = "" + } + if deezerISRC == "" && req.SpotifyID != "" { songlinkClient := backend.NewSongLinkClient() @@ -370,6 +384,7 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) { } if err != nil { + backend.FailDownloadItem(itemID, fmt.Sprintf("Download failed: %v", err)) if filename != "" && !strings.HasPrefix(filename, "EXISTS:") { @@ -828,16 +843,19 @@ type DownloadFFmpegResponse struct { } func (a *App) DownloadFFmpeg() DownloadFFmpegResponse { + runtime.EventsEmit(a.ctx, "ffmpeg:status", "starting") err := backend.DownloadFFmpeg(func(progress int) { - fmt.Printf("[FFmpeg] Download progress: %d%%\n", progress) + runtime.EventsEmit(a.ctx, "ffmpeg:progress", progress) }) if err != nil { + runtime.EventsEmit(a.ctx, "ffmpeg:status", "failed") return DownloadFFmpegResponse{ Success: false, Error: err.Error(), } } + runtime.EventsEmit(a.ctx, "ffmpeg:status", "completed") return DownloadFFmpegResponse{ Success: true, Message: "FFmpeg installed successfully", @@ -1105,3 +1123,7 @@ func (a *App) LoadSettings() (map[string]interface{}, error) { return settings, nil } + +func (a *App) CheckFFmpegInstalled() (bool, error) { + return backend.IsFFmpegInstalled() +} diff --git a/backend/qobuz.go b/backend/qobuz.go index f64a23b..c641f4b 100644 --- a/backend/qobuz.go +++ b/backend/qobuz.go @@ -127,12 +127,12 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string, } fmt.Printf("Getting download URL for track ID: %d with requested quality: %s\n", trackID, qualityCode) - fmt.Printf("Quality codes: 6=FLAC 16-bit, 7=FLAC 24-bit, 27=Hi-Res\n") + fmt.Printf("Quality codes: 6=FLAC 16-bit, 7=FLAC 24-bit\n") primaryBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9kYWIueWVldC5zdS9hcGkvc3RyZWFtP3RyYWNrSWQ9") primaryURL := fmt.Sprintf("%s%d&quality=%s", string(primaryBase), trackID, qualityCode) - fmt.Printf("Qobuz API URL: %s\n", primaryURL) + fmt.Printf("Trying Primary API: %s\n", primaryURL) resp, err := q.client.Get(primaryURL) if err == nil && resp.StatusCode == 200 { @@ -143,7 +143,7 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string, var streamResp QobuzStreamResponse if err := json.Unmarshal(body, &streamResp); err == nil && streamResp.URL != "" { - fmt.Printf("Got download URL from primary API\n") + fmt.Printf("✓ Got download URL from Primary API\n") return streamResp.URL, nil } } @@ -151,20 +151,43 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string, resp.Body.Close() } - fmt.Println("Primary API failed, trying fallback...") + fmt.Println("Primary API failed, trying Fallback API #1...") fallbackBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9kYWJtdXNpYy54eXovYXBpL3N0cmVhbT90cmFja0lkPQ==") fallbackURL := fmt.Sprintf("%s%d&quality=%s", string(fallbackBase), trackID, qualityCode) resp, err = q.client.Get(fallbackURL) + if err == nil && resp.StatusCode == 200 { + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err == nil && len(body) > 0 { + fmt.Printf("Fallback API #1 response: %s\n", string(body)) + + var streamResp QobuzStreamResponse + if err := json.Unmarshal(body, &streamResp); err == nil && streamResp.URL != "" { + fmt.Printf("✓ Got download URL from Fallback API #1\n") + return streamResp.URL, nil + } + } + } + if resp != nil { + resp.Body.Close() + } + + fmt.Println("Fallback API #1 failed, trying Fallback API #2...") + fallback2Base, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9xb2J1ei5zcXVpZC53dGYvYXBpL2Rvd25sb2FkLW11c2ljP3RyYWNrX2lkPQ==") + fallback2URL := fmt.Sprintf("%s%d&quality=%s", string(fallback2Base), trackID, qualityCode) + + resp, err = q.client.Get(fallback2URL) if err != nil { - return "", fmt.Errorf("failed to get download URL: %w", err) + return "", fmt.Errorf("all APIs failed to get download URL: %w", err) } defer resp.Body.Close() if resp.StatusCode != 200 { body, _ := io.ReadAll(resp.Body) - fmt.Printf("Fallback API error response: %s\n", string(body)) - return "", fmt.Errorf("API returned status %d", resp.StatusCode) + fmt.Printf("Fallback API #2 error response (status %d): %s\n", resp.StatusCode, string(body)) + return "", fmt.Errorf("all APIs returned non-200 status") } body, err := io.ReadAll(resp.Body) @@ -176,7 +199,7 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string, return "", fmt.Errorf("API returned empty response") } - fmt.Printf("Fallback API response: %s\n", string(body)) + fmt.Printf("Fallback API #2 response: %s\n", string(body)) var streamResp QobuzStreamResponse if err := json.Unmarshal(body, &streamResp); err != nil { @@ -189,10 +212,10 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string, } if streamResp.URL == "" { - return "", fmt.Errorf("no download URL available") + return "", fmt.Errorf("no download URL available from any API") } - fmt.Printf("Got download URL from fallback API\n") + fmt.Printf("✓ Got download URL from Fallback API #2\n") return streamResp.URL, nil } diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 5e6b472..13d2e6b 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -1,23 +1,22 @@ -import js from '@eslint/js' -import globals from 'globals' -import reactHooks from 'eslint-plugin-react-hooks' -import reactRefresh from 'eslint-plugin-react-refresh' -import tseslint from 'typescript-eslint' -import { defineConfig, globalIgnores } from 'eslint/config' - +import js from '@eslint/js'; +import globals from 'globals'; +import reactHooks from 'eslint-plugin-react-hooks'; +import reactRefresh from 'eslint-plugin-react-refresh'; +import tseslint from 'typescript-eslint'; +import { defineConfig, globalIgnores } from 'eslint/config'; export default defineConfig([ - globalIgnores(['dist']), - { - files: ['**/*.{ts,tsx}'], - extends: [ - js.configs.recommended, - tseslint.configs.recommended, - reactHooks.configs.flat.recommended, - reactRefresh.configs.vite, - ], - languageOptions: { - ecmaVersion: 2020, - globals: globals.browser, + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, }, - }, -]) +]); diff --git a/frontend/scripts/generate-icon.js b/frontend/scripts/generate-icon.js index b975dd8..02eec68 100644 --- a/frontend/scripts/generate-icon.js +++ b/frontend/scripts/generate-icon.js @@ -2,32 +2,23 @@ import sharp from 'sharp'; import { readFileSync, mkdirSync } from 'fs'; import { dirname, join } from 'path'; import { fileURLToPath } from 'url'; - const __dirname = dirname(fileURLToPath(import.meta.url)); const rootDir = join(__dirname, '..', '..'); - const svgPath = join(rootDir, 'frontend', 'public', 'icon.svg'); const outputPath = join(rootDir, 'build', 'appicon.png'); - async function generateIcon() { - try { - // Ensure build directory exists - mkdirSync(join(rootDir, 'build'), { recursive: true }); - - // Read SVG - const svgBuffer = readFileSync(svgPath); - - // Convert SVG to PNG (1024x1024 for Wails) - await sharp(svgBuffer) - .resize(1024, 1024) - .png() - .toFile(outputPath); - - console.log('✓ Icon generated:', outputPath); - } catch (error) { - console.error('✗ Failed to generate icon:', error.message); - process.exit(1); - } + try { + mkdirSync(join(rootDir, 'build'), { recursive: true }); + const svgBuffer = readFileSync(svgPath); + await sharp(svgBuffer) + .resize(1024, 1024) + .png() + .toFile(outputPath); + console.log('✓ Icon generated:', outputPath); + } + catch (error) { + console.error('✗ Failed to generate icon:', error.message); + process.exit(1); + } } - generateIcon(); diff --git a/frontend/src/components/DownloadQueue.tsx b/frontend/src/components/DownloadQueue.tsx index bdeb15f..0e4c033 100644 --- a/frontend/src/components/DownloadQueue.tsx +++ b/frontend/src/components/DownloadQueue.tsx @@ -3,7 +3,8 @@ import { X, Download, CheckCircle2, XCircle, Clock, FileCheck, Trash2, HardDrive import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Badge } from "@/components/ui/badge"; -import { GetDownloadQueue, ClearCompletedDownloads } from "../../wailsjs/go/main/App"; +import { GetDownloadQueue, ClearCompletedDownloads, ClearAllDownloads } from "../../wailsjs/go/main/App"; +import { toastWithSound as toast } from "@/lib/toast-with-sound"; import { backend } from "../../wailsjs/go/models"; interface DownloadQueueProps { isOpen: boolean; @@ -47,6 +48,17 @@ export function DownloadQueue({ isOpen, onClose }: DownloadQueueProps) { console.error("Failed to clear history:", error); } }; + const handleReset = async () => { + try { + await ClearAllDownloads(); + const info = await GetDownloadQueue(); + setQueueInfo(info); + toast.success("Download queue reset"); + } + catch (error) { + console.error("Failed to reset queue:", error); + } + }; const getStatusIcon = (status: string) => { switch (status) { case "downloading": @@ -72,8 +84,8 @@ export function DownloadQueue({ isOpen, onClose }: DownloadQueueProps) { queued: "outline", }; return ( - {status} - ); + {status} + ); }; const formatDuration = (startTimestamp: number) => { if (startTimestamp === 0) @@ -94,138 +106,138 @@ export function DownloadQueue({ isOpen, onClose }: DownloadQueueProps) { } }; return ( - - -
- Download Queue -
- {(queueInfo.completed_count > 0 || queueInfo.failed_count > 0 || queueInfo.skipped_count > 0) && ()} - -
+ + +
+ Download Queue +
+ {(queueInfo.completed_count > 0 || queueInfo.failed_count > 0 || queueInfo.skipped_count > 0) && ()} +
+
- -
-
- - Queued: - {queueInfo.queued_count} -
-
- - Completed: - {queueInfo.completed_count} -
-
- - Skipped: - {queueInfo.skipped_count} -
-
- - Failed: - {queueInfo.failed_count} -
+ +
+
+ + Queued: + {queueInfo.queued_count}
+
+ + Completed: + {queueInfo.completed_count} +
+
+ + Skipped: + {queueInfo.skipped_count} +
+
+ + Failed: + {queueInfo.failed_count} +
+
- -
-
- - Downloaded: - - {queueInfo.total_downloaded > 0 ? `${queueInfo.total_downloaded.toFixed(2)} MB` : "0.00 MB"} - -
-
- - Speed: - - {queueInfo.current_speed > 0 && queueInfo.is_downloading + +
+
+ + Downloaded: + + {queueInfo.total_downloaded > 0 ? `${queueInfo.total_downloaded.toFixed(2)} MB` : "0.00 MB"} + +
+
+ + Speed: + + {queueInfo.current_speed > 0 && queueInfo.is_downloading ? `${queueInfo.current_speed.toFixed(2)} MB/s` : "—"} - -
-
- - Duration: - - {queueInfo.session_start_time > 0 ? formatDuration(queueInfo.session_start_time) : "—"} - -
+
+
+ + Duration: + + {queueInfo.session_start_time > 0 ? formatDuration(queueInfo.session_start_time) : "—"} + +
+
- + - -
-
- {queueInfo.queue.length === 0 ? (
- -

No downloads in queue

-
) : (queueInfo.queue.map((item) => (
-
-
{getStatusIcon(item.status)}
-
-
-
-

{item.track_name}

-

- {item.artist_name} - {item.album_name && ` • ${item.album_name}`} -

-
- {getStatusBadge(item.status)} -
+
+
+ {queueInfo.queue.length === 0 ? (
+ +

No downloads in queue

+
) : (queueInfo.queue.map((item) => (
+
+
{getStatusIcon(item.status)}
- - {item.status === "downloading" && (
- - {item.progress > 0 +
+
+
+

{item.track_name}

+

+ {item.artist_name} + {item.album_name && ` • ${item.album_name}`} +

+
+ {getStatusBadge(item.status)} +
+ + + {item.status === "downloading" && (
+ + {item.progress > 0 ? `${item.progress.toFixed(2)} MB` : queueInfo.is_downloading && queueInfo.current_speed > 0 ? "Downloading..." : "Starting..."} - - - {item.speed > 0 + + + {item.speed > 0 ? `${item.speed.toFixed(2)} MB/s` : queueInfo.current_speed > 0 ? `${queueInfo.current_speed.toFixed(2)} MB/s` : "—"} - -
)} + +
)} - - {item.status === "completed" && (
- {item.progress.toFixed(2)} MB -
)} - - {item.status === "skipped" && (
- File already exists -
)} + {item.status === "completed" && (
+ {item.progress.toFixed(2)} MB +
)} - - {item.status === "failed" && item.error_message && (
- {item.error_message} -
)} - - {(item.status === "completed" || item.status === "skipped") && item.file_path && (
- {item.file_path} -
)} -
-
-
)))} -
+ {item.status === "skipped" && (
+ File already exists +
)} + + + {item.status === "failed" && item.error_message && (
+ {item.error_message} +
)} + + + {(item.status === "completed" || item.status === "skipped") && item.file_path && (
+ {item.file_path} +
)} +
+
+
)))}
- -
); + + + ); } diff --git a/frontend/src/components/SettingsPage.tsx b/frontend/src/components/SettingsPage.tsx index 8fba270..e2210f1 100644 --- a/frontend/src/components/SettingsPage.tsx +++ b/frontend/src/components/SettingsPage.tsx @@ -8,10 +8,12 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip import { FolderOpen, Save, RotateCcw, Info } from "lucide-react"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Switch } from "@/components/ui/switch"; +import { Progress } from "@/components/ui/progress"; import { getSettings, getSettingsWithDefaults, saveSettings, resetToDefaultSettings, applyThemeMode, applyFont, FONT_OPTIONS, FOLDER_PRESETS, FILENAME_PRESETS, TEMPLATE_VARIABLES, type Settings as SettingsType, type FontFamily, type FolderPreset, type FilenamePreset } from "@/lib/settings"; import { themes, applyTheme } from "@/lib/themes"; import { SelectFolder } from "../../wailsjs/go/main/App"; import { toastWithSound as toast } from "@/lib/toast-with-sound"; +import { useDownloadProgress } from "@/hooks/useDownloadProgress"; const TidalIcon = () => ( @@ -33,6 +35,10 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting const [tempSettings, setTempSettings] = useState(savedSettings); const [isDark, setIsDark] = useState(document.documentElement.classList.contains('dark')); const [showResetConfirm, setShowResetConfirm] = useState(false); + const [showFFmpegWarning, setShowFFmpegWarning] = useState(false); + const [isInstallingFFmpeg, setIsInstallingFFmpeg] = useState(false); + const [installProgress, setInstallProgress] = useState(0); + const downloadProgress = useDownloadProgress(); const hasUnsavedChanges = JSON.stringify(savedSettings) !== JSON.stringify(tempSettings); const resetToSaved = useCallback(() => { const freshSavedSettings = getSettings(); @@ -109,6 +115,51 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting toast.error(`Error selecting folder: ${error}`); } }; + const handleTidalQualityChange = async (value: "LOSSLESS" | "HI_RES_LOSSLESS") => { + if (value === "HI_RES_LOSSLESS") { + try { + const { CheckFFmpegInstalled } = await import("../../wailsjs/go/main/App"); + const isInstalled = await CheckFFmpegInstalled(); + if (!isInstalled) { + setShowFFmpegWarning(true); + return; + } + } + catch (error) { + console.error("Error checking FFmpeg:", error); + } + } + setTempSettings((prev) => ({ ...prev, tidalQuality: value })); + }; + const handleInstallFFmpeg = async () => { + setIsInstallingFFmpeg(true); + setInstallProgress(0); + try { + const { DownloadFFmpeg } = await import("../../wailsjs/go/main/App"); + const { EventsOn, EventsOff } = await import("../../wailsjs/runtime/runtime"); + EventsOn("ffmpeg:progress", (progress: number) => { + setInstallProgress(progress); + }); + const response = await DownloadFFmpeg(); + EventsOff("ffmpeg:progress"); + if (response.success) { + toast.success("FFmpeg installed successfully!"); + setShowFFmpegWarning(false); + setTempSettings((prev) => ({ ...prev, tidalQuality: "HI_RES_LOSSLESS" })); + } + else { + toast.error(`Failed to install FFmpeg: ${response.error}`); + } + } + catch (error) { + console.error("Error installing FFmpeg:", error); + toast.error(`Error during FFmpeg installation: ${error}`); + } + finally { + setIsInstallingFFmpeg(false); + setInstallProgress(0); + } + }; return (

Settings

@@ -208,7 +259,7 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting - {tempSettings.downloader === "tidal" && ( @@ -218,14 +269,13 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting )} - {tempSettings.downloader === "qobuz" && ( setTempSettings((prev) => ({ ...prev, qobuzQuality: value }))}> FLAC 16-bit (CD Quality) - FLAC 24-bit - Hi-Res (24-bit/96kHz+) + FLAC 24-bit (Studio Quality) )} @@ -234,7 +284,7 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting - Hi-Res (24-bit/96kHz+) + Hi-Res (24-bit/48kHz+) )}
@@ -357,5 +407,37 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: Setting + + !isInstallingFFmpeg && setShowFFmpegWarning(open)}> + + + FFmpeg Required + +
+

Tidal 24-bit (Hi-Res Lossless) downloads audio in segmented files that need to be merged into a single FLAC file.

+

FFmpeg is required to merge these segments. {isInstallingFFmpeg ? "Installing FFmpeg..." : "Would you like to install FFmpeg now?"}

+
+ + {isInstallingFFmpeg && (
+
+
+ Downloading & Extracting... + {downloadProgress.is_downloading && downloadProgress.mb_downloaded > 0 && ( + {downloadProgress.mb_downloaded.toFixed(2)} MB + {downloadProgress.speed_mbps > 0 && ` @ ${downloadProgress.speed_mbps.toFixed(2)} MB/s`} + )} +
+ {installProgress}% +
+ +
)} +
+
+ {!isInstallingFFmpeg && ( + + + )} +
+
); } diff --git a/frontend/src/lib/settings.ts b/frontend/src/lib/settings.ts index ffe0a89..86974d4 100644 --- a/frontend/src/lib/settings.ts +++ b/frontend/src/lib/settings.ts @@ -21,7 +21,7 @@ export interface Settings { embedMaxQualityCover: boolean; operatingSystem: "Windows" | "linux/MacOS"; tidalQuality: "LOSSLESS" | "HI_RES_LOSSLESS"; - qobuzQuality: "6" | "7" | "27"; + qobuzQuality: "6" | "7"; amazonQuality: "HI_RES"; } export const FOLDER_PRESETS: Record { if (!('qobuzQuality' in parsed)) { parsed.qobuzQuality = "6"; } + if (parsed.qobuzQuality === "27") { + parsed.qobuzQuality = "6"; + } if (!('amazonQuality' in parsed)) { parsed.amazonQuality = "HI_RES"; } diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 79a65e0..67039fd 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,14 +1,12 @@ -import path from "path" -import tailwindcss from "@tailwindcss/vite" -import react from "@vitejs/plugin-react" -import { defineConfig } from "vite" - -// https://vite.dev/config/ +import path from "path"; +import tailwindcss from "@tailwindcss/vite"; +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; export default defineConfig({ - plugins: [react(), tailwindcss()], - resolve: { - alias: { - "@": path.resolve(__dirname, "./src"), + plugins: [react(), tailwindcss()], + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, }, - }, -}) +});