From b160d3c7909aa43248f9866273dc84c508317051 Mon Sep 17 00:00:00 2001 From: afkarxyz Date: Thu, 15 Jan 2026 11:03:27 +0700 Subject: [PATCH] v7.0.6 --- app.go | 108 +++ backend/analysis.go | 43 ++ backend/history.go | 149 +++++ frontend/package.json | 3 +- frontend/package.json.md5 | 2 +- frontend/pnpm-lock.yaml | 32 + frontend/src/App.tsx | 211 ++++-- frontend/src/assets/icons/exyezed.svg | 16 + frontend/src/assets/icons/spotidownloader.svg | 23 + frontend/src/assets/icons/spotiflac.svg | 39 ++ frontend/src/assets/icons/spotubedl.svg | 1 + frontend/src/assets/icons/xbatchdl.svg | 11 + frontend/src/components/AboutPage.tsx | 270 ++++++++ .../src/components/AudioConverterPage.tsx | 307 ++++----- frontend/src/components/FileManagerPage.tsx | 632 ++++++++---------- frontend/src/components/HistoryPage.tsx | 281 ++++++++ frontend/src/components/SettingsPage.tsx | 435 ++++++------ frontend/src/components/Sidebar.tsx | 208 +++--- frontend/src/components/ui/badge-alert.tsx | 61 ++ frontend/src/components/ui/blocks.tsx | 52 -- frontend/src/components/ui/coffee.tsx | 13 +- frontend/src/components/ui/github.tsx | 102 --- frontend/src/components/ui/history-icon.tsx | 97 +++ frontend/src/components/ui/tabs.tsx | 16 + frontend/src/components/ui/terminal.tsx | 9 +- frontend/src/components/ui/textarea.tsx | 6 + frontend/src/components/ui/toggle.tsx | 1 - frontend/src/hooks/useDownload.ts | 469 +++++++------ frontend/src/lib/logger.ts | 2 +- frontend/src/lib/settings.ts | 54 +- go.mod | 1 + go.sum | 4 + main.go | 1 + wails.json | 2 +- 34 files changed, 2368 insertions(+), 1293 deletions(-) create mode 100644 backend/history.go create mode 100644 frontend/src/assets/icons/exyezed.svg create mode 100644 frontend/src/assets/icons/spotidownloader.svg create mode 100644 frontend/src/assets/icons/spotiflac.svg create mode 100644 frontend/src/assets/icons/spotubedl.svg create mode 100644 frontend/src/assets/icons/xbatchdl.svg create mode 100644 frontend/src/components/AboutPage.tsx create mode 100644 frontend/src/components/HistoryPage.tsx create mode 100644 frontend/src/components/ui/badge-alert.tsx delete mode 100644 frontend/src/components/ui/blocks.tsx delete mode 100644 frontend/src/components/ui/github.tsx create mode 100644 frontend/src/components/ui/history-icon.tsx create mode 100644 frontend/src/components/ui/tabs.tsx create mode 100644 frontend/src/components/ui/textarea.tsx diff --git a/app.go b/app.go index bde6d3e..d8a22b8 100644 --- a/app.go +++ b/app.go @@ -6,8 +6,10 @@ import ( "encoding/json" "fmt" "os" + "os/exec" "path/filepath" "regexp" + goRuntime "runtime" "spotiflac/backend" "strings" "time" @@ -31,6 +33,14 @@ func NewApp() *App { func (a *App) startup(ctx context.Context) { a.ctx = ctx + + if err := backend.InitHistoryDB("SpotiFLAC"); err != nil { + fmt.Printf("Failed to init history DB: %v\n", err) + } +} + +func (a *App) shutdown(ctx context.Context) { + backend.CloseHistoryDB() } type SpotifyMetadataRequest struct { @@ -471,6 +481,40 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) { backend.CompleteDownloadItem(itemID, filename, 0) } + + go func(fPath, track, artist, album, sID, cover, format string) { + quality := "Unknown" + durationStr := "--:--" + + meta, err := backend.GetTrackMetadata(fPath) + if err == nil && meta != nil { + quality = fmt.Sprintf("%d-bit/%.1fkHz", meta.BitsPerSample, float64(meta.SampleRate)/1000.0) + d := int(meta.Duration) + durationStr = fmt.Sprintf("%d:%02d", d/60, d%60) + } else { + + } + + item := backend.HistoryItem{ + SpotifyID: sID, + Title: track, + Artists: artist, + Album: album, + DurationStr: durationStr, + CoverURL: cover, + Quality: quality, + Format: format, + Path: fPath, + } + + if item.Format == "" || item.Format == "LOSSLESS" { + ext := filepath.Ext(fPath) + if len(ext) > 1 { + item.Format = strings.ToUpper(ext[1:]) + } + } + backend.AddHistoryItem(item, "SpotiFLAC") + }(filename, req.TrackName, req.ArtistName, req.AlbumName, req.SpotifyID, req.CoverURL, req.AudioFormat) } return DownloadResponse{ @@ -544,6 +588,14 @@ func (a *App) Quit() { panic("quit") } +func (a *App) GetDownloadHistory() ([]backend.HistoryItem, error) { + return backend.GetHistoryItems("SpotiFLAC") +} + +func (a *App) ClearDownloadHistory() error { + return backend.ClearHistory("SpotiFLAC") +} + func (a *App) AnalyzeTrack(filePath string) (string, error) { if filePath == "" { return "", fmt.Errorf("file path is required") @@ -1127,3 +1179,59 @@ func (a *App) LoadSettings() (map[string]interface{}, error) { func (a *App) CheckFFmpegInstalled() (bool, error) { return backend.IsFFmpegInstalled() } + +func (a *App) GetOSInfo() (string, error) { + osType := goRuntime.GOOS + arch := goRuntime.GOARCH + + switch osType { + case "windows": + out, err := exec.Command("wmic", "os", "get", "Caption,Version", "/value").Output() + if err != nil { + outVer, errVer := exec.Command("cmd", "/c", "ver").Output() + if errVer != nil { + return fmt.Sprintf("Windows %s", arch), nil + } + return strings.TrimSpace(string(outVer)), nil + } + + lines := strings.Split(string(out), "\n") + var caption, version string + for _, line := range lines { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "Caption=") { + caption = strings.TrimPrefix(line, "Caption=") + } else if strings.HasPrefix(line, "Version=") { + version = strings.TrimPrefix(line, "Version=") + } + } + if caption != "" && version != "" { + return fmt.Sprintf("%s (%s, %s)", caption, version, arch), nil + } + return strings.TrimSpace(string(out)), nil + + case "darwin": + out, err := exec.Command("sw_vers", "-productVersion").Output() + if err != nil { + return fmt.Sprintf("macOS %s", arch), nil + } + version := strings.TrimSpace(string(out)) + return fmt.Sprintf("macOS %s (%s)", version, arch), nil + + case "linux": + out, err := exec.Command("cat", "/etc/os-release").Output() + if err == nil { + lines := strings.Split(string(out), "\n") + for _, line := range lines { + if strings.HasPrefix(line, "PRETTY_NAME=") { + name := strings.Trim(strings.TrimPrefix(line, "PRETTY_NAME="), "\"") + return fmt.Sprintf("%s (%s)", name, arch), nil + } + } + } + return fmt.Sprintf("Linux %s", arch), nil + + default: + return fmt.Sprintf("%s %s", osType, arch), nil + } +} diff --git a/backend/analysis.go b/backend/analysis.go index 9762c0a..209a232 100644 --- a/backend/analysis.go +++ b/backend/analysis.go @@ -162,3 +162,46 @@ func GetFileSize(filepath string) (int64, error) { } return info.Size(), nil } + +func GetTrackMetadata(filepath string) (*AnalysisResult, error) { + if !fileExists(filepath) { + return nil, fmt.Errorf("file does not exist: %s", filepath) + } + + fileInfo, err := os.Stat(filepath) + if err != nil { + return nil, fmt.Errorf("failed to get file info: %w", err) + } + + f, err := flac.ParseFile(filepath) + if err != nil { + return nil, fmt.Errorf("failed to parse FLAC file: %w", err) + } + + result := &AnalysisResult{ + FilePath: filepath, + FileSize: fileInfo.Size(), + } + + if len(f.Meta) > 0 { + streamInfo := f.Meta[0] + if streamInfo.Type == flac.StreamInfo { + data := streamInfo.Data + if len(data) >= 18 { + result.SampleRate = uint32(data[10])<<12 | uint32(data[11])<<4 | uint32(data[12])>>4 + result.BitsPerSample = ((data[12]&0x01)<<4 | data[13]>>4) + 1 + result.TotalSamples = uint64(data[13]&0x0F)<<32 | + uint64(data[14])<<24 | + uint64(data[15])<<16 | + uint64(data[16])<<8 | + uint64(data[17]) + + if result.SampleRate > 0 { + result.Duration = float64(result.TotalSamples) / float64(result.SampleRate) + } + } + } + } + result.BitDepth = fmt.Sprintf("%d-bit", result.BitsPerSample) + return result, nil +} diff --git a/backend/history.go b/backend/history.go new file mode 100644 index 0000000..606be35 --- /dev/null +++ b/backend/history.go @@ -0,0 +1,149 @@ +package backend + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "time" + + bolt "go.etcd.io/bbolt" +) + +type HistoryItem struct { + ID string `json:"id"` + SpotifyID string `json:"spotify_id"` + Title string `json:"title"` + Artists string `json:"artists"` + Album string `json:"album"` + DurationStr string `json:"duration_str"` + CoverURL string `json:"cover_url"` + Quality string `json:"quality"` + Format string `json:"format"` + Path string `json:"path"` + Timestamp int64 `json:"timestamp"` +} + +var historyDB *bolt.DB + +const ( + historyBucket = "DownloadHistory" + maxHistory = 10000 +) + +func InitHistoryDB(appName string) error { + + appDir, err := GetFFmpegDir() + if err != nil { + return err + } + if _, err := os.Stat(appDir); os.IsNotExist(err) { + os.MkdirAll(appDir, 0755) + } + dbPath := filepath.Join(appDir, "history.db") + + db, err := bolt.Open(dbPath, 0600, &bolt.Options{Timeout: 1 * time.Second}) + if err != nil { + return err + } + + err = db.Update(func(tx *bolt.Tx) error { + _, err := tx.CreateBucketIfNotExists([]byte(historyBucket)) + return err + }) + + if err != nil { + db.Close() + return err + } + + historyDB = db + return nil +} + +func CloseHistoryDB() { + if historyDB != nil { + historyDB.Close() + } +} + +func AddHistoryItem(item HistoryItem, appName string) error { + if historyDB == nil { + if err := InitHistoryDB(appName); err != nil { + return err + } + } + return historyDB.Update(func(tx *bolt.Tx) error { + b := tx.Bucket([]byte(historyBucket)) + id, _ := b.NextSequence() + + item.ID = fmt.Sprintf("%d-%d", time.Now().UnixNano(), id) + item.Timestamp = time.Now().Unix() + + buf, err := json.Marshal(item) + if err != nil { + return err + } + + if b.Stats().KeyN >= maxHistory { + c := b.Cursor() + + toDelete := maxHistory / 20 + if toDelete < 1 { + toDelete = 1 + } + + count := 0 + for k, _ := c.First(); k != nil && count < toDelete; k, _ = c.Next() { + if err := b.Delete(k); err != nil { + return err + } + count++ + } + } + + return b.Put([]byte(item.ID), buf) + }) +} + +func GetHistoryItems(appName string) ([]HistoryItem, error) { + if historyDB == nil { + if err := InitHistoryDB(appName); err != nil { + return nil, err + } + } + var items []HistoryItem + err := historyDB.View(func(tx *bolt.Tx) error { + b := tx.Bucket([]byte(historyBucket)) + if b == nil { + return nil + } + c := b.Cursor() + + for k, v := c.First(); k != nil; k, v = c.Next() { + var item HistoryItem + if err := json.Unmarshal(v, &item); err == nil { + items = append(items, item) + } + } + return nil + }) + + sort.Slice(items, func(i, j int) bool { + return items[i].Timestamp > items[j].Timestamp + }) + + return items, err +} + +func ClearHistory(appName string) error { + if historyDB == nil { + if err := InitHistoryDB(appName); err != nil { + return err + } + } + return historyDB.Update(func(tx *bolt.Tx) error { + return tx.DeleteBucket([]byte(historyBucket)) + }) +} diff --git a/frontend/package.json b/frontend/package.json index ef63ef9..ea236b6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -20,6 +20,7 @@ "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-switch": "^1.2.6", + "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-toggle": "^1.1.10", "@radix-ui/react-toggle-group": "^1.1.11", "@radix-ui/react-tooltip": "^1.2.8", @@ -51,4 +52,4 @@ "typescript-eslint": "^8.53.0", "vite": "^7.3.1" } -} +} \ No newline at end of file diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index 78dabe9..5e86848 100644 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -68754ba75ba7fe058dd9ebf6593e2759 \ No newline at end of file +42597f825aff483763c8cb00c83bfa74 \ No newline at end of file diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index d613ad9..93d63b0 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: '@radix-ui/react-switch': specifier: ^1.2.6 version: 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-tabs': + specifier: ^1.1.13 + version: 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@radix-ui/react-toggle': specifier: ^1.1.10 version: 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -896,6 +899,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-tabs@1.1.13': + resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-toggle-group@1.1.11': resolution: {integrity: sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==} peerDependencies: @@ -2767,6 +2783,22 @@ snapshots: '@types/react': 19.2.8 '@types/react-dom': 19.2.3(@types/react@19.2.8) + '@radix-ui/react-tabs@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-context': 1.1.2(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.8)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + '@radix-ui/react-toggle-group@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@radix-ui/primitive': 1.1.3 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4e1ecde..fa4a20d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7,7 +7,8 @@ import { Search, X, ArrowUp } from "lucide-react"; import { TooltipProvider } from "@/components/ui/tooltip"; import { getSettings, getSettingsWithDefaults, loadSettings, saveSettings, applyThemeMode, applyFont } from "@/lib/settings"; import { applyTheme } from "@/lib/themes"; -import { OpenFolder } from "../wailsjs/go/main/App"; +import { OpenFolder, CheckFFmpegInstalled, DownloadFFmpeg } from "../wailsjs/go/main/App"; +import { EventsOn, EventsOff, Quit } from "../wailsjs/runtime/runtime"; import { toastWithSound as toast } from "@/lib/toast-with-sound"; import { TitleBar } from "@/components/TitleBar"; import { Sidebar, type PageType } from "@/components/Sidebar"; @@ -24,6 +25,8 @@ import { AudioConverterPage } from "@/components/AudioConverterPage"; import { FileManagerPage } from "@/components/FileManagerPage"; import { SettingsPage } from "@/components/SettingsPage"; import { DebugLoggerPage } from "@/components/DebugLoggerPage"; +import { AboutPage } from "@/components/AboutPage"; +import { HistoryPage } from "@/components/HistoryPage"; import type { HistoryItem } from "@/components/FetchHistory"; import { useDownload } from "@/hooks/useDownload"; import { useMetadata } from "@/hooks/useMetadata"; @@ -31,6 +34,7 @@ import { useLyrics } from "@/hooks/useLyrics"; import { useCover } from "@/hooks/useCover"; import { useAvailability } from "@/hooks/useAvailability"; import { useDownloadQueueDialog } from "@/hooks/useDownloadQueueDialog"; +import { useDownloadProgress } from "@/hooks/useDownloadProgress"; const HISTORY_KEY = "spotiflac_fetch_history"; const MAX_HISTORY = 5; function App() { @@ -50,13 +54,18 @@ function App() { const [showUnsavedChangesDialog, setShowUnsavedChangesDialog] = useState(false); const [resetSettingsFn, setResetSettingsFn] = useState<(() => void) | null>(null); const ITEMS_PER_PAGE = 50; - const CURRENT_VERSION = "7.0.5"; + const CURRENT_VERSION = "7.0.6"; const download = useDownload(); const metadata = useMetadata(); const lyrics = useLyrics(); const cover = useCover(); const availability = useAvailability(); const downloadQueue = useDownloadQueueDialog(); + const downloadProgress = useDownloadProgress(); + const [isFFmpegInstalled, setIsFFmpegInstalled] = useState(null); + const [isInstallingFFmpeg, setIsInstallingFFmpeg] = useState(false); + const [ffmpegInstallProgress, setFfmpegInstallProgress] = useState(0); + const [ffmpegInstallStatus, setFfmpegInstallStatus] = useState(""); useLayoutEffect(() => { const savedSettings = getSettings(); if (savedSettings) { @@ -65,7 +74,6 @@ function App() { applyFont(savedSettings.fontFamily); } }, []); - useEffect(() => { const initSettings = async () => { const settings = await loadSettings(); @@ -78,6 +86,17 @@ function App() { } }; initSettings(); + const checkFFmpeg = async () => { + try { + const installed = await CheckFFmpegInstalled(); + setIsFFmpegInstalled(installed); + } + catch (err) { + console.error("Failed to check FFmpeg:", err); + setIsFFmpegInstalled(false); + } + }; + checkFFmpeg(); const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); const handleChange = () => { const currentSettings = getSettings(); @@ -138,6 +157,44 @@ function App() { console.error("Failed to load history:", err); } }; + const handleInstallFFmpeg = async () => { + setIsInstallingFFmpeg(true); + setFfmpegInstallProgress(0); + setFfmpegInstallStatus("starting"); + try { + EventsOn("ffmpeg:progress", (progress: number) => { + setFfmpegInstallProgress(progress); + if (progress >= 100) { + setFfmpegInstallStatus("extracting"); + } + else { + setFfmpegInstallStatus("downloading"); + } + }); + EventsOn("ffmpeg:status", (status: string) => { + setFfmpegInstallStatus(status); + }); + const response = await DownloadFFmpeg(); + EventsOff("ffmpeg:progress"); + EventsOff("ffmpeg:status"); + if (response.success) { + toast.success("FFmpeg installed successfully!"); + setIsFFmpegInstalled(true); + } + 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); + setFfmpegInstallProgress(0); + setFfmpegInstallStatus(""); + } + }; const saveHistory = (history: HistoryItem[]) => { try { localStorage.setItem(HISTORY_KEY, JSON.stringify(history)); @@ -262,49 +319,49 @@ function App() { return null; if ("track" in metadata.metadata) { const { track } = metadata.metadata; - return ( lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, track.album_name, undefined, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _playlistName, _position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, track.album_name, undefined, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onOpenFolder={handleOpenFolder} />); + return ( lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, track.album_name, undefined, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _playlistName, _position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, track.album_name, undefined, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onOpenFolder={handleOpenFolder}/>); } if ("album_info" in metadata.metadata) { const { album_info, track_list } = metadata.metadata; return ( 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} 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); - } - }} />); + 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); + } + }}/>); } if ("playlist_info" in metadata.metadata) { const { playlist_info, track_list } = metadata.metadata; return ( lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, playlist_info.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.name, position, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, playlist_info.name)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, playlist_info.name)} onDownloadAll={() => download.handleDownloadAll(track_list, playlist_info.name)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, playlist_info.name)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onPageChange={setCurrentListPage} 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); - } - }} />); + 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); + } + }}/>); } if ("artist_info" in metadata.metadata) { const { artist_info, album_list, track_list } = metadata.metadata; return ( 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} onArtistClick={async (artist) => { - const artistUrl = await metadata.handleArtistClick(artist); - if (artistUrl) { - setSpotifyUrl(artistUrl); - } - }} onPageChange={setCurrentListPage} onTrackClick={async (track) => { - if (track.external_urls) { - setSpotifyUrl(track.external_urls); - await metadata.handleFetchMetadata(track.external_urls); - } - }} />); + const artistUrl = await metadata.handleArtistClick(artist); + if (artistUrl) { + setSpotifyUrl(artistUrl); + } + }} onPageChange={setCurrentListPage} onTrackClick={async (track) => { + if (track.external_urls) { + setSpotifyUrl(track.external_urls); + await metadata.handleFetchMetadata(track.external_urls); + } + }}/>); } return null; }; @@ -337,9 +394,13 @@ function App() { const renderPage = () => { switch (currentPage) { case "settings": - return ; + return ; case "debug": return ; + case "about": + return ; + case "history": + return ; case "audio-analysis": return ; case "audio-converter": @@ -348,14 +409,14 @@ function App() { return ; default: return (<> -
+
Fetch Artist @@ -369,7 +430,7 @@ function App() {
- metadata.setTimeoutValue(Number(e.target.value))} /> + metadata.setTimeoutValue(Number(e.target.value))}/>

Default: 60 seconds. For large discographies, try 300-600 seconds (5-10 minutes). @@ -381,7 +442,7 @@ function App() { Cancel @@ -393,7 +454,7 @@ function App() {

Fetch Album @@ -408,12 +469,12 @@ function App() { Cancel @@ -426,7 +487,7 @@ function App() { if (updatedUrl) { setSpotifyUrl(updatedUrl); } - }} history={fetchHistory} onHistorySelect={handleHistorySelect} onHistoryRemove={removeFromHistory} hasResult={!!metadata.metadata} searchMode={isSearchMode} onSearchModeChange={setIsSearchMode} /> + }} history={fetchHistory} onHistorySelect={handleHistorySelect} onHistoryRemove={removeFromHistory} hasResult={!!metadata.metadata} searchMode={isSearchMode} onSearchModeChange={setIsSearchMode}/> {!isSearchMode && metadata.metadata && renderMetadata()} ); @@ -435,7 +496,7 @@ function App() { return (
- +
@@ -445,14 +506,14 @@ function App() {
- + - + {showScrollTop && ()} @@ -474,6 +535,54 @@ function App() {
+ + + { }}> + + + + FFmpeg Required + + + FFmpeg is essential for SpotiFLAC to function properly. + This setup will download about 100-200MB of data. + + + + {isInstallingFFmpeg && (
+ {ffmpegInstallStatus === "extracting" ? (
+
+
+ Extracting... +
+ Finalizing setup +
) : (
+
+
+ Downloading... + {downloadProgress.is_downloading && downloadProgress.mb_downloaded > 0 && ( + {downloadProgress.mb_downloaded.toFixed(1)}MB + {downloadProgress.speed_mbps > 0 && ` @ ${downloadProgress.speed_mbps.toFixed(1)}MB/s`} + )} +
+ {ffmpegInstallProgress}% +
+
+
+
+
)} +
)} + + + {!isInstallingFFmpeg && ()} + + + +
); } diff --git a/frontend/src/assets/icons/exyezed.svg b/frontend/src/assets/icons/exyezed.svg new file mode 100644 index 0000000..136cff8 --- /dev/null +++ b/frontend/src/assets/icons/exyezed.svg @@ -0,0 +1,16 @@ + + + + + + + + diff --git a/frontend/src/assets/icons/spotidownloader.svg b/frontend/src/assets/icons/spotidownloader.svg new file mode 100644 index 0000000..a1da83b --- /dev/null +++ b/frontend/src/assets/icons/spotidownloader.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/assets/icons/spotiflac.svg b/frontend/src/assets/icons/spotiflac.svg new file mode 100644 index 0000000..cd3d7b2 --- /dev/null +++ b/frontend/src/assets/icons/spotiflac.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/assets/icons/spotubedl.svg b/frontend/src/assets/icons/spotubedl.svg new file mode 100644 index 0000000..09e2249 --- /dev/null +++ b/frontend/src/assets/icons/spotubedl.svg @@ -0,0 +1 @@ + diff --git a/frontend/src/assets/icons/xbatchdl.svg b/frontend/src/assets/icons/xbatchdl.svg new file mode 100644 index 0000000..11c11d5 --- /dev/null +++ b/frontend/src/assets/icons/xbatchdl.svg @@ -0,0 +1,11 @@ + + + + + + + diff --git a/frontend/src/components/AboutPage.tsx b/frontend/src/components/AboutPage.tsx new file mode 100644 index 0000000..397597a --- /dev/null +++ b/frontend/src/components/AboutPage.tsx @@ -0,0 +1,270 @@ +import { useState, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { openExternal } from "@/lib/utils"; +import { GetOSInfo } from "../../wailsjs/go/main/App"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; +import { Bug, Lightbulb, ExternalLink } from "lucide-react"; +import ExyezedIcon from "@/assets/icons/exyezed.svg"; +import SpotubeDLIcon from "@/assets/icons/spotubedl.svg"; +import SpotiDownloaderIcon from "@/assets/icons/spotidownloader.svg"; +import XBatchDLIcon from "@/assets/icons/xbatchdl.svg"; +interface AboutPageProps { + version: string; +} +export function AboutPage({ version }: AboutPageProps) { + const [os, setOs] = useState("Unknown"); + const [location, setLocation] = useState("Unknown"); + const [reportType, setReportType] = useState("bug"); + const [problem, setProblem] = useState(""); + const [bugType, setBugType] = useState("Track"); + const [spotifyUrl, setSpotifyUrl] = useState(""); + const [bugContext, setBugContext] = useState(""); + const [featureDesc, setFeatureDesc] = useState(""); + const [useCase, setUseCase] = useState(""); + const [featureContext, setFeatureContext] = useState(""); + useEffect(() => { + const fetchOS = async () => { + try { + const info = await GetOSInfo(); + setOs(info); + } + catch (err) { + const userAgent = window.navigator.userAgent; + if (userAgent.indexOf("Win") !== -1) + setOs("Windows"); + else if (userAgent.indexOf("Mac") !== -1) + setOs("macOS"); + else if (userAgent.indexOf("Linux") !== -1) + setOs("Linux"); + } + }; + fetchOS(); + const fetchLocation = async () => { + try { + const response = await fetch('https://ipapi.co/json/'); + if (response.ok) { + const data = await response.json(); + const city = data.city || ''; + const region = data.region || ''; + const country = data.country_name || ''; + const parts = [city, region, country].filter(Boolean); + setLocation(parts.join(', ') || 'Unknown'); + } + else { + const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; + setLocation(timezone); + } + } + catch (err) { + const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; + setLocation(timezone); + } + }; + fetchLocation(); + }, []); + const faqs = [ + { + q: "Is this software free?", + a: "Yes. This software is completely free. You do not need an account, login, or subscription. All you need is an internet connection." + }, + { + q: "Can using this software get my Spotify account suspended or banned?", + a: "No. This software has no connection to your Spotify account. Spotify data is obtained through reverse engineering of the Spotify Web Player, not through user authentication." + }, + { + q: "Where does the audio come from?", + a: "The audio is fetched using third-party APIs." + }, + { + q: "Why does metadata fetching sometimes fail?", + a: "This usually happens because your IP address has been rate-limited. You can wait and try again later, or use a VPN to bypass the rate limit." + }, + { + q: "Why does Windows Defender or antivirus flag or delete the file?", + a: "This is a false positive. It likely happens because the executable is compressed using UPX. If you are concerned, you can fork the repository and build the software yourself from source." + } + ]; + const sanitizeForURL = (text: string): string => { + return text.replace(/[()]/g, "").replace(/,/g, " -"); + }; + const handleSubmit = () => { + let title = ""; + let body = ""; + if (reportType === "bug") { + title = `[Bug Report] ${problem ? problem.substring(0, 50) + (problem.length > 50 ? "..." : "") : "Issue"}`; + body = `### [Bug Report] + +#### Problem +> ${problem || "Type here"} + +#### Type +${bugType || "Track / Album / Playlist / Artist"} + +#### Spotify URL +> ${spotifyUrl || "Type here"} + +#### Additional Context +> ${bugContext || "Type here or send screenshot/recording"} + +#### Version +SpotiFLAC v${version} + +#### OS +${sanitizeForURL(os || "Unknown")} + +#### Location +${location || "Unknown"} +`; + } + else { + title = `[Feature Request] ${featureDesc ? featureDesc.substring(0, 50) + (featureDesc.length > 50 ? "..." : "") : "Request"}`; + body = `### [Feature Request] + +#### Description +> ${featureDesc || "Type here"} + +#### Use Case +> ${useCase || "Type here"} + +#### Additional Context +> ${featureContext || "Type here or send screenshot/recording"} +`; + } + const url = `https://github.com/afkarxyz/SpotiFLAC/issues/new?title=${encodeURIComponent(title)}&body=${encodeURIComponent(body)}`; + openExternal(url); + }; + return (
+
+

About

+
+ + + + Report Issue + FAQ + Other Projects + + + + + + + + Bug Report + Feature Request + + +
+ {reportType === "bug" ? (
+
+ +