Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f13359df7f | |||
| ff14990bd8 | |||
| 0e62356183 | |||
| acec3c350e | |||
| fc520c1cc4 | |||
| 6137995fea | |||
| ae2e4eb155 | |||
| eb468b16df | |||
| 1be4d825fd | |||
| 01d039947a | |||
| 066b6bcbdb | |||
| b3273b7602 | |||
| d495a9851c | |||
| 6f5fd1d16e | |||
| f4b7049f4a | |||
| 4cccdcae77 | |||
| c21d08f050 | |||
| 00d3fb9212 | |||
| 7b12866334 | |||
| 1b415961cc | |||
| 74001462b4 | |||
| fdca1ab461 | |||
| 3d8ff2cedd | |||
| 9ef24f5a91 | |||
| 1314c14c59 | |||
| cb3a6a32cb |
+1
-3
@@ -1,3 +1 @@
|
|||||||
github: afkarxyz
|
ko_fi: afkarxyz
|
||||||
ko_fi: afkarxyz
|
|
||||||
buy_me_a_coffee: afkarxyz
|
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
name: Bug Report
|
||||||
|
description: Bug Report
|
||||||
|
title: "[Bug Report] "
|
||||||
|
labels: ["bug"]
|
||||||
|
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
> **WARNING: Issues that do not follow this template will be deleted without review.**
|
||||||
|
>
|
||||||
|
> **Please keep `[Bug Report]` in the issue title and only continue after it.**
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: problem
|
||||||
|
attributes:
|
||||||
|
label: Problem
|
||||||
|
placeholder: e.g. Downloading a playlist stops after the first track with no error message.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: type
|
||||||
|
attributes:
|
||||||
|
label: Type
|
||||||
|
description: Select the Spotify item type related to this bug.
|
||||||
|
options:
|
||||||
|
- Track
|
||||||
|
- Album
|
||||||
|
- Playlist
|
||||||
|
- Artist
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: spotify-url
|
||||||
|
attributes:
|
||||||
|
label: Spotify URL
|
||||||
|
placeholder: e.g. https://open.spotify.com/track/4cOdK2wGLETKBW3PvgPWqT
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: additional-context
|
||||||
|
attributes:
|
||||||
|
label: Additional Context
|
||||||
|
placeholder: e.g. Happens every time on this link. Screenshot or recording attached.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: "### Environment"
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: version
|
||||||
|
attributes:
|
||||||
|
label: SpotiDownloader Version
|
||||||
|
placeholder: e.g. v7.1.0
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: os
|
||||||
|
attributes:
|
||||||
|
label: OS
|
||||||
|
placeholder: e.g. Windows 11 23H2
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: location
|
||||||
|
attributes:
|
||||||
|
label: Location
|
||||||
|
placeholder: e.g. Indonesia
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
name: Feature Request
|
||||||
|
description: Feature Request
|
||||||
|
title: "[Feature Request] "
|
||||||
|
labels: ["enhancement"]
|
||||||
|
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
> **WARNING: Issues that do not follow this template will be deleted without review.**
|
||||||
|
>
|
||||||
|
> **Please keep `[Feature Request]` in the issue title and only continue after it.**
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: description
|
||||||
|
attributes:
|
||||||
|
label: Description
|
||||||
|
placeholder: e.g. Add an option to choose the output naming format for downloaded tracks.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: use-case
|
||||||
|
attributes:
|
||||||
|
label: Use Case
|
||||||
|
placeholder: e.g. I want downloaded files to follow a custom format like Artist - Title for easier library management.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: additional-context
|
||||||
|
attributes:
|
||||||
|
label: Additional Context
|
||||||
|
placeholder: e.g. Similar tools allow custom naming templates. Screenshot or mockup attached if needed.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
@@ -7,7 +7,7 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
GO_VERSION: '1.25.5'
|
GO_VERSION: '1.26'
|
||||||
NODE_VERSION: '24'
|
NODE_VERSION: '24'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|||||||
@@ -1,36 +1,28 @@
|
|||||||
[](https://github.com/afkarxyz/SpotiFLAC/releases)
|
# SpotiFLAC
|
||||||
[](https://t.me/spotiflac)
|
|
||||||
[](https://t.me/spotiflac_chat)
|
|
||||||
|
|
||||||

|
<a href="https://trendshift.io/repositories/15737" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15737" alt="afkarxyz%2FSpotiFLAC | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||||
|
|
||||||
<div align="center">
|
|
||||||
|
|
||||||
Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account required.
|
Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account required.
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
|
[](https://t.me/spotiflac)
|
||||||
<a href="https://trendshift.io/repositories/15737" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15737" alt="afkarxyz%2FSpotiFLAC | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
[](https://t.me/spotiflac_chat)
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
### [Download](https://github.com/afkarxyz/SpotiFLAC/releases)
|
### [Download](https://github.com/afkarxyz/SpotiFLAC/releases)
|
||||||
|
|
||||||
## Screenshot
|

|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
## Other projects
|
## Other projects
|
||||||
|
|
||||||
### [SpotiFLAC Next](https://github.com/spotiverse/SpotiFLAC-Next)
|
### [SpotiFLAC Next](https://github.com/afkarxyz/SpotiFLAC-Next)
|
||||||
|
|
||||||
Get Spotify tracks in Hi-Res lossless FLACs — no account required.
|
Get Spotify tracks in true FLAC from Tidal, Qobuz, Amazon Music & Deezer — no account required.
|
||||||
|
|
||||||
### [SpotiDownloader](https://github.com/afkarxyz/SpotiDownloader)
|
### [SpotiDownloader](https://github.com/afkarxyz/SpotiDownloader)
|
||||||
|
|
||||||
Get Spotify tracks in MP3 and FLAC via spotidownloader.com
|
Get Spotify tracks, albums, playlists and discography in MP3 and FLAC.
|
||||||
|
|
||||||
### [SpotubeDL](https://spotubedl.com)
|
### [SpotubeDL](https://spotubedl.com)
|
||||||
|
|
||||||
@@ -40,50 +32,75 @@ Download Spotify Tracks, Albums, Playlists as MP3/OGG/Opus with High Quality.
|
|||||||
|
|
||||||
SpotiFLAC for Android & iOS — maintained by [@zarzet](https://github.com/zarzet)
|
SpotiFLAC for Android & iOS — maintained by [@zarzet](https://github.com/zarzet)
|
||||||
|
|
||||||
|
### [SpotiFLAC (CLI)](https://github.com/Nizarberyan/SpotiFLAC)
|
||||||
|
|
||||||
|
SpotiFLAC for command-line environments — maintained by [@Nizarberyan](https://github.com/Nizarberyan)
|
||||||
|
|
||||||
|
### [SpotiFLAC (Python Module)](https://github.com/ShuShuzinhuu/SpotiFLAC-Module-Version)
|
||||||
|
|
||||||
|
SpotiFLAC Python library for SpotiFLAC integration — maintained by [@ShuShuzinhuu](https://github.com/ShuShuzinhuu)
|
||||||
|
|
||||||
## FAQ
|
## FAQ
|
||||||
|
|
||||||
### Is this software free?
|
<details>
|
||||||
|
<summary>Is this software free?</summary>
|
||||||
|
|
||||||
_Yes. This software is completely free.
|
_Yes. This software is completely free.
|
||||||
You do not need an account, login, or subscription.
|
You do not need an account, login, or subscription.
|
||||||
All you need is an internet connection._
|
All you need is an internet connection._
|
||||||
|
|
||||||
### Can using this software get my Spotify account suspended or banned?
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Can using this software get my Spotify account suspended or banned?</summary>
|
||||||
|
|
||||||
_No.
|
_No.
|
||||||
This software has no connection to your Spotify account.
|
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._
|
Spotify data is obtained through reverse engineering of the Spotify Web Player, not through user authentication._
|
||||||
|
|
||||||
### Where does the audio come from?
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Where does the audio come from?</summary>
|
||||||
|
|
||||||
_The audio is fetched using third-party APIs._
|
_The audio is fetched using third-party APIs._
|
||||||
|
|
||||||
### Why does metadata fetching sometimes fail?
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Why does metadata fetching sometimes fail?</summary>
|
||||||
|
|
||||||
_This usually happens because your IP address has been rate-limited.
|
_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._
|
You can wait and try again later, or use a VPN to bypass the rate limit._
|
||||||
|
|
||||||
### Why does Windows Defender or antivirus flag or delete the file?
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Why does Windows Defender or antivirus flag or delete the file?</summary>
|
||||||
|
|
||||||
_This is a false positive.
|
_This is a false positive.
|
||||||
It likely happens because the executable is compressed using UPX._
|
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._
|
_If you are concerned, you can fork the repository and build the software yourself from source._
|
||||||
|
|
||||||
### Want to support the project?
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Want to support the project?</summary>
|
||||||
|
|
||||||
_If this software is useful and brings you value,
|
_If this software is useful and brings you value,
|
||||||
consider supporting the project by buying me a coffee.
|
consider supporting the project by buying me a coffee.
|
||||||
Your support helps keep development going._
|
Your support helps keep development going._
|
||||||
|
|
||||||
[](https://ko-fi.com/afkarxyz)
|
</details>
|
||||||
[](https://www.buymeacoffee.com/afkarxyz)
|
|
||||||
|
[](https://ko-fi.com/afkarxyz)
|
||||||
|
|
||||||
## Disclaimer
|
## Disclaimer
|
||||||
|
|
||||||
This project is for **educational and private use only**. The developer does not condone or encourage copyright infringement.
|
This project is for **educational and private use only**. The developer does not condone or encourage copyright infringement.
|
||||||
|
|
||||||
**SpotiFLAC** is a third-party tool and is not affiliated with, endorsed by, or connected to Spotify, Tidal, Qobuz, Amazon Music, or any other streaming service.
|
**SpotiFLAC** is a third-party tool and is not affiliated with, endorsed by, or connected to Spotify, Tidal, Qobuz, Amazon Music or any other streaming service.
|
||||||
|
|
||||||
You are solely responsible for:
|
You are solely responsible for:
|
||||||
|
|
||||||
@@ -95,9 +112,10 @@ The software is provided "as is", without warranty of any kind. The author assum
|
|||||||
|
|
||||||
## API Credits
|
## API Credits
|
||||||
|
|
||||||
- **Tidal**: [hifi-api](https://github.com/binimum/hifi-api)
|
[MusicBrainz](https://musicbrainz.org) · [LRCLIB](https://lrclib.net) · [Song.link](https://song.link) · [hifi-api](https://github.com/binimum/hifi-api) · [dabmusic.xyz](https://dabmusic.xyz)
|
||||||
- **Qobuz**: [dabmusic.xyz](https://dabmusic.xyz), [squid.wtf](https://squid.wtf), [jumo-dl](https://jumo-dl.pages.dev/)
|
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
>
|
>
|
||||||
> **Star Us**, You will receive all release notifications from GitHub without any delay ~
|
> **Star Us**, You will receive all release notifications from GitHub without any delay ~
|
||||||
|
|
||||||
|
[](https://repostars.dev/?repos=afkarxyz%2FSpotiFLAC&theme=forest)
|
||||||
|
|||||||
@@ -5,24 +5,20 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
|
||||||
|
|
||||||
"spotiflac/backend"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/afkarxyz/SpotiFLAC/backend"
|
||||||
|
|
||||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
"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 {
|
type App struct {
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
}
|
}
|
||||||
@@ -31,6 +27,19 @@ func NewApp() *App {
|
|||||||
return &App{}
|
return &App{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) getFirstArtist(artistString string) string {
|
||||||
|
if artistString == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
delimiters := []string{", ", " & ", " feat. ", " ft. ", " featuring "}
|
||||||
|
for _, d := range delimiters {
|
||||||
|
if idx := strings.Index(strings.ToLower(artistString), d); idx != -1 {
|
||||||
|
return strings.TrimSpace(artistString[:idx])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return artistString
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) startup(ctx context.Context) {
|
func (a *App) startup(ctx context.Context) {
|
||||||
a.ctx = ctx
|
a.ctx = ctx
|
||||||
|
|
||||||
@@ -44,14 +53,14 @@ func (a *App) shutdown(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type SpotifyMetadataRequest struct {
|
type SpotifyMetadataRequest struct {
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
Batch bool `json:"batch"`
|
Batch bool `json:"batch"`
|
||||||
Delay float64 `json:"delay"`
|
Delay float64 `json:"delay"`
|
||||||
Timeout float64 `json:"timeout"`
|
Timeout float64 `json:"timeout"`
|
||||||
|
Separator string `json:"separator,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DownloadRequest struct {
|
type DownloadRequest struct {
|
||||||
ISRC string `json:"isrc"`
|
|
||||||
Service string `json:"service"`
|
Service string `json:"service"`
|
||||||
Query string `json:"query,omitempty"`
|
Query string `json:"query,omitempty"`
|
||||||
TrackName string `json:"track_name,omitempty"`
|
TrackName string `json:"track_name,omitempty"`
|
||||||
@@ -82,6 +91,10 @@ type DownloadRequest struct {
|
|||||||
PlaylistName string `json:"playlist_name,omitempty"`
|
PlaylistName string `json:"playlist_name,omitempty"`
|
||||||
PlaylistOwner string `json:"playlist_owner,omitempty"`
|
PlaylistOwner string `json:"playlist_owner,omitempty"`
|
||||||
AllowFallback bool `json:"allow_fallback"`
|
AllowFallback bool `json:"allow_fallback"`
|
||||||
|
UseFirstArtistOnly bool `json:"use_first_artist_only,omitempty"`
|
||||||
|
UseSingleGenre bool `json:"use_single_genre,omitempty"`
|
||||||
|
EmbedGenre bool `json:"embed_genre,omitempty"`
|
||||||
|
Separator string `json:"separator,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DownloadResponse struct {
|
type DownloadResponse struct {
|
||||||
@@ -93,6 +106,22 @@ type DownloadResponse struct {
|
|||||||
ItemID string `json:"item_id,omitempty"`
|
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) {
|
func (a *App) GetStreamingURLs(spotifyTrackID string, region string) (string, error) {
|
||||||
if spotifyTrackID == "" {
|
if spotifyTrackID == "" {
|
||||||
return "", fmt.Errorf("spotify track ID is required")
|
return "", fmt.Errorf("spotify track ID is required")
|
||||||
@@ -129,12 +158,27 @@ func (a *App) GetSpotifyMetadata(req SpotifyMetadataRequest) (string, error) {
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
settings, err := a.LoadSettings()
|
settings, err := a.LoadSettings()
|
||||||
|
separator := req.Separator
|
||||||
|
if separator == "" {
|
||||||
|
separator = ", "
|
||||||
|
if err == nil && settings != nil {
|
||||||
|
if sep, ok := settings["separator"].(string); ok {
|
||||||
|
if sep == "semicolon" {
|
||||||
|
separator = "; "
|
||||||
|
} else if sep == "comma" {
|
||||||
|
separator = ", "
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err == nil && settings != nil {
|
if err == nil && settings != nil {
|
||||||
if useAPI, ok := settings["useSpotFetchAPI"].(bool); ok && useAPI {
|
if useAPI, ok := settings["useSpotFetchAPI"].(bool); ok && useAPI {
|
||||||
if apiURL, ok := settings["spotFetchAPIUrl"].(string); ok && apiURL != "" {
|
if apiURL, ok := settings["spotFetchAPIUrl"].(string); ok && apiURL != "" {
|
||||||
|
|
||||||
data, err := backend.GetSpotifyDataWithAPI(ctx, req.URL, true, apiURL, req.Batch, time.Duration(req.Delay*float64(time.Second)))
|
data, err := backend.GetSpotifyDataWithAPI(ctx, req.URL, true, apiURL, req.Batch, time.Duration(req.Delay*float64(time.Second)), separator, func(tracks interface{}) {
|
||||||
|
runtime.EventsEmit(a.ctx, "metadata-stream", tracks)
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to fetch metadata from API: %v", err)
|
return "", fmt.Errorf("failed to fetch metadata from API: %v", err)
|
||||||
}
|
}
|
||||||
@@ -149,7 +193,9 @@ func (a *App) GetSpotifyMetadata(req SpotifyMetadataRequest) (string, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := backend.GetFilteredSpotifyData(ctx, req.URL, req.Batch, time.Duration(req.Delay*float64(time.Second)))
|
data, err := backend.GetFilteredSpotifyData(ctx, req.URL, req.Batch, time.Duration(req.Delay*float64(time.Second)), separator, func(tracks interface{}) {
|
||||||
|
runtime.EventsEmit(a.ctx, "metadata-stream", tracks)
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to fetch metadata: %v", err)
|
return "", fmt.Errorf("failed to fetch metadata: %v", err)
|
||||||
}
|
}
|
||||||
@@ -210,7 +256,7 @@ func (a *App) SearchSpotifyByType(req SpotifySearchByTypeRequest) ([]backend.Sea
|
|||||||
|
|
||||||
func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
||||||
|
|
||||||
if req.Service == "qobuz" && req.ISRC == "" && req.SpotifyID == "" {
|
if req.Service == "qobuz" && req.SpotifyID == "" {
|
||||||
return DownloadResponse{
|
return DownloadResponse{
|
||||||
Success: false,
|
Success: false,
|
||||||
Error: "Spotify ID is required for Qobuz",
|
Error: "Spotify ID is required for Qobuz",
|
||||||
@@ -270,7 +316,21 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
trackURL := fmt.Sprintf("https://open.spotify.com/track/%s", req.SpotifyID)
|
trackURL := fmt.Sprintf("https://open.spotify.com/track/%s", req.SpotifyID)
|
||||||
trackData, err := backend.GetFilteredSpotifyData(ctx, trackURL, false, 0)
|
metadataSeparator := req.Separator
|
||||||
|
if metadataSeparator == "" {
|
||||||
|
metadataSeparator = ", "
|
||||||
|
metadataSettings, _ := a.LoadSettings()
|
||||||
|
if metadataSettings != nil {
|
||||||
|
if sep, ok := metadataSettings["separator"].(string); ok {
|
||||||
|
if sep == "semicolon" {
|
||||||
|
metadataSeparator = "; "
|
||||||
|
} else if sep == "comma" {
|
||||||
|
metadataSeparator = ", "
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
trackData, err := backend.GetFilteredSpotifyData(ctx, trackURL, false, 0, metadataSeparator, nil)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|
||||||
var trackResp struct {
|
var trackResp struct {
|
||||||
@@ -326,89 +386,76 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lyricsChan := make(chan string, 1)
|
||||||
|
isrcChan := make(chan string, 1)
|
||||||
|
|
||||||
|
if req.SpotifyID != "" {
|
||||||
|
if req.EmbedLyrics {
|
||||||
|
go func() {
|
||||||
|
client := backend.NewLyricsClient()
|
||||||
|
resp, _, err := client.FetchLyricsAllSources(req.SpotifyID, req.TrackName, req.ArtistName, req.AlbumName, req.Duration)
|
||||||
|
if err == nil && resp != nil && len(resp.Lines) > 0 {
|
||||||
|
lrc := client.ConvertToLRC(resp, req.TrackName, req.ArtistName)
|
||||||
|
lyricsChan <- lrc
|
||||||
|
} else {
|
||||||
|
lyricsChan <- ""
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
} else {
|
||||||
|
close(lyricsChan)
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Service == "qobuz" {
|
||||||
|
go func() {
|
||||||
|
client := backend.NewSongLinkClient()
|
||||||
|
isrc, _ := client.GetISRCDirect(req.SpotifyID)
|
||||||
|
isrcChan <- isrc
|
||||||
|
}()
|
||||||
|
} else {
|
||||||
|
close(isrcChan)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
close(lyricsChan)
|
||||||
|
close(isrcChan)
|
||||||
|
}
|
||||||
|
|
||||||
switch req.Service {
|
switch req.Service {
|
||||||
case "amazon":
|
case "amazon":
|
||||||
|
|
||||||
downloader := backend.NewAmazonDownloader()
|
downloader := backend.NewAmazonDownloader()
|
||||||
if req.ServiceURL != "" {
|
if req.ServiceURL != "" {
|
||||||
filename, err = downloader.DownloadByURL(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.PlaylistName, req.PlaylistOwner, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.CoverURL, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.EmbedMaxQualityCover, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL)
|
filename, err = downloader.DownloadByURL(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.PlaylistName, req.PlaylistOwner, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.CoverURL, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.EmbedMaxQualityCover, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre)
|
||||||
} else {
|
} else {
|
||||||
if req.SpotifyID == "" {
|
filename, err = downloader.DownloadBySpotifyID(req.SpotifyID, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.PlaylistName, req.PlaylistOwner, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.CoverURL, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.EmbedMaxQualityCover, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre)
|
||||||
return DownloadResponse{
|
|
||||||
Success: false,
|
|
||||||
Error: "Spotify ID is required for Amazon Music",
|
|
||||||
}, fmt.Errorf("spotify ID is required for Amazon Music")
|
|
||||||
}
|
|
||||||
filename, err = downloader.DownloadBySpotifyID(req.SpotifyID, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.PlaylistName, req.PlaylistOwner, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.CoverURL, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.EmbedMaxQualityCover, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
case "tidal":
|
case "tidal":
|
||||||
if req.ApiURL == "" || req.ApiURL == "auto" {
|
if req.ApiURL == "" || req.ApiURL == "auto" {
|
||||||
downloader := backend.NewTidalDownloader("")
|
downloader := backend.NewTidalDownloader("")
|
||||||
if req.ServiceURL != "" {
|
if req.ServiceURL != "" {
|
||||||
filename, err = downloader.DownloadByURLWithFallback(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.AllowFallback)
|
filename, err = downloader.DownloadByURLWithFallback(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre)
|
||||||
} else {
|
} else {
|
||||||
if req.SpotifyID == "" {
|
filename, err = downloader.Download(req.SpotifyID, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre)
|
||||||
return DownloadResponse{
|
|
||||||
Success: false,
|
|
||||||
Error: "Spotify ID is required for Tidal",
|
|
||||||
}, fmt.Errorf("spotify ID is required for Tidal")
|
|
||||||
}
|
|
||||||
filename, err = downloader.Download(req.SpotifyID, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.AllowFallback)
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
downloader := backend.NewTidalDownloader(req.ApiURL)
|
downloader := backend.NewTidalDownloader(req.ApiURL)
|
||||||
if req.ServiceURL != "" {
|
if req.ServiceURL != "" {
|
||||||
filename, err = downloader.DownloadByURL(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.AllowFallback)
|
filename, err = downloader.DownloadByURL(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre)
|
||||||
} else {
|
} else {
|
||||||
if req.SpotifyID == "" {
|
filename, err = downloader.Download(req.SpotifyID, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre)
|
||||||
return DownloadResponse{
|
|
||||||
Success: false,
|
|
||||||
Error: "Spotify ID is required for Tidal",
|
|
||||||
}, fmt.Errorf("spotify ID is required for Tidal")
|
|
||||||
}
|
|
||||||
filename, err = downloader.Download(req.SpotifyID, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.AllowFallback)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
case "qobuz":
|
case "qobuz":
|
||||||
downloader := backend.NewQobuzDownloader()
|
|
||||||
|
|
||||||
|
fmt.Println("Waiting for ISRC (Qobuz dependency)...")
|
||||||
|
isrc := <-isrcChan
|
||||||
|
downloader := backend.NewQobuzDownloader()
|
||||||
quality := req.AudioFormat
|
quality := req.AudioFormat
|
||||||
if quality == "" {
|
if quality == "" {
|
||||||
quality = "6"
|
quality = "6"
|
||||||
}
|
}
|
||||||
|
filename, err = downloader.DownloadTrackWithISRC(isrc, req.SpotifyID, req.OutputDir, quality, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre)
|
||||||
deezerISRC := req.ISRC
|
|
||||||
|
|
||||||
if len(deezerISRC) != 12 || !isValidISRC(deezerISRC) {
|
|
||||||
deezerISRC = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
if deezerISRC == "" && req.SpotifyID != "" {
|
|
||||||
|
|
||||||
songlinkClient := backend.NewSongLinkClient()
|
|
||||||
deezerURL, err := songlinkClient.GetDeezerURLFromSpotify(req.SpotifyID)
|
|
||||||
if err != nil {
|
|
||||||
return DownloadResponse{
|
|
||||||
Success: false,
|
|
||||||
Error: fmt.Sprintf("Failed to get Deezer URL: %v", err),
|
|
||||||
}, err
|
|
||||||
}
|
|
||||||
deezerISRC, err = backend.GetDeezerISRC(deezerURL)
|
|
||||||
if err != nil {
|
|
||||||
return DownloadResponse{
|
|
||||||
Success: false,
|
|
||||||
Error: fmt.Sprintf("Failed to get ISRC from Deezer: %v", err),
|
|
||||||
}, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if deezerISRC == "" {
|
|
||||||
return DownloadResponse{
|
|
||||||
Success: false,
|
|
||||||
Error: "ISRC is required for Qobuz (could not fetch from Deezer)",
|
|
||||||
}, fmt.Errorf("ISRC is required for Qobuz")
|
|
||||||
}
|
|
||||||
filename, err = downloader.DownloadByISRC(deezerISRC, req.OutputDir, quality, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.AllowFallback)
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return DownloadResponse{
|
return DownloadResponse{
|
||||||
@@ -443,53 +490,47 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
|||||||
filename = strings.TrimPrefix(filename, "EXISTS:")
|
filename = strings.TrimPrefix(filename, "EXISTS:")
|
||||||
}
|
}
|
||||||
|
|
||||||
if !alreadyExists && req.SpotifyID != "" && req.EmbedLyrics && strings.HasSuffix(filename, ".flac") {
|
if !alreadyExists {
|
||||||
go func(filePath, spotifyID, trackName, artistName string) {
|
validated, validationErr := backend.ValidateDownloadedTrackDuration(filename, req.Duration)
|
||||||
fmt.Printf("\n========== LYRICS FETCH START ==========\n")
|
if validationErr != nil {
|
||||||
fmt.Printf("Spotify ID: %s\n", spotifyID)
|
cleanupInvalidDownloadArtifacts(filename)
|
||||||
fmt.Printf("Track: %s\n", trackName)
|
errorMessage := validationErr.Error()
|
||||||
fmt.Printf("Artist: %s\n", artistName)
|
backend.FailDownloadItem(itemID, errorMessage)
|
||||||
fmt.Println("Searching all sources...")
|
return DownloadResponse{
|
||||||
|
Success: false,
|
||||||
lyricsClient := backend.NewLyricsClient()
|
Error: errorMessage,
|
||||||
|
ItemID: itemID,
|
||||||
lyricsResp, source, err := lyricsClient.FetchLyricsAllSources(spotifyID, trackName, artistName, 0)
|
}, fmt.Errorf(errorMessage)
|
||||||
if err != nil {
|
}
|
||||||
fmt.Printf("All sources failed: %v\n", err)
|
if !validated {
|
||||||
fmt.Printf("========== LYRICS FETCH END (FAILED) ==========\n\n")
|
fmt.Printf("[DownloadValidation] Skipped duration validation for %s (expected=%ds)\n", filename, req.Duration)
|
||||||
return
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if lyricsResp == nil || len(lyricsResp.Lines) == 0 {
|
|
||||||
fmt.Println("No lyrics content found")
|
|
||||||
fmt.Printf("========== LYRICS FETCH END (FAILED) ==========\n\n")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("Lyrics found from: %s\n", source)
|
|
||||||
fmt.Printf("Sync type: %s\n", lyricsResp.SyncType)
|
|
||||||
fmt.Printf("Total lines: %d\n", len(lyricsResp.Lines))
|
|
||||||
|
|
||||||
lyrics := lyricsClient.ConvertToLRC(lyricsResp, trackName, artistName)
|
|
||||||
if lyrics == "" {
|
|
||||||
fmt.Println("No lyrics content to embed")
|
|
||||||
fmt.Printf("========== LYRICS FETCH END (FAILED) ==========\n\n")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
|
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
|
||||||
|
if lyrics != "" {
|
||||||
fmt.Printf("\n--- Full LRC Content ---\n")
|
fmt.Printf("\n--- Full LRC Content ---\n")
|
||||||
fmt.Println(lyrics)
|
fmt.Println(lyrics)
|
||||||
fmt.Printf("--- End LRC Content ---\n\n")
|
fmt.Printf("--- End LRC Content ---\n\n")
|
||||||
|
|
||||||
fmt.Printf("Embedding into: %s\n", filePath)
|
fmt.Printf("Embedding into: %s\n", filename)
|
||||||
if err := backend.EmbedLyricsOnly(filePath, lyrics); err != nil {
|
|
||||||
|
if err := backend.EmbedLyricsOnlyUniversal(filename, lyrics); err != nil {
|
||||||
fmt.Printf("Failed to embed lyrics: %v\n", err)
|
fmt.Printf("Failed to embed lyrics: %v\n", err)
|
||||||
fmt.Printf("========== LYRICS FETCH END (FAILED) ==========\n\n")
|
|
||||||
} else {
|
} else {
|
||||||
fmt.Printf("Lyrics embedded successfully!\n")
|
fmt.Printf("Lyrics embedded successfully!\n")
|
||||||
fmt.Printf("========== LYRICS FETCH END (SUCCESS) ==========\n\n")
|
|
||||||
}
|
}
|
||||||
}(filename, req.SpotifyID, req.TrackName, req.ArtistName)
|
} else {
|
||||||
|
fmt.Println("No lyrics found to embed.")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-lyricsChan:
|
||||||
|
default:
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
message := "Download completed successfully"
|
message := "Download completed successfully"
|
||||||
@@ -497,6 +538,14 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
|||||||
message = "File already exists"
|
message = "File already exists"
|
||||||
backend.SkipDownloadItem(itemID, filename)
|
backend.SkipDownloadItem(itemID, filename)
|
||||||
} else {
|
} 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 {
|
if fileInfo, statErr := os.Stat(filename); statErr == nil {
|
||||||
finalSize := float64(fileInfo.Size()) / (1024 * 1024)
|
finalSize := float64(fileInfo.Size()) / (1024 * 1024)
|
||||||
@@ -506,17 +555,23 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
|||||||
backend.CompleteDownloadItem(itemID, filename, 0)
|
backend.CompleteDownloadItem(itemID, filename, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
go func(fPath, track, artist, album, sID, cover, format string) {
|
go func(fPath, track, artist, album, sID, cover, format, source string) {
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
|
||||||
quality := "Unknown"
|
quality := "Unknown"
|
||||||
durationStr := "--:--"
|
durationStr := "0:00"
|
||||||
|
|
||||||
meta, err := backend.GetTrackMetadata(fPath)
|
meta, err := backend.GetTrackMetadata(fPath)
|
||||||
if err == nil && meta != nil {
|
if err == nil {
|
||||||
quality = fmt.Sprintf("%d-bit/%.1fkHz", meta.BitsPerSample, float64(meta.SampleRate)/1000.0)
|
if meta.Bitrate > 0 {
|
||||||
|
quality = fmt.Sprintf("%dkbps/%.1fkHz", meta.Bitrate/1000, float64(meta.SampleRate)/1000.0)
|
||||||
|
} else if meta.SampleRate > 0 {
|
||||||
|
quality = fmt.Sprintf("%.1fkHz", float64(meta.SampleRate)/1000.0)
|
||||||
|
}
|
||||||
d := int(meta.Duration)
|
d := int(meta.Duration)
|
||||||
durationStr = fmt.Sprintf("%d:%02d", d/60, d%60)
|
durationStr = fmt.Sprintf("%d:%02d", d/60, d%60)
|
||||||
} else {
|
} else {
|
||||||
|
fmt.Printf("[History] Failed to get metadata for %s: %v\n", fPath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
item := backend.HistoryItem{
|
item := backend.HistoryItem{
|
||||||
@@ -527,8 +582,9 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
|||||||
DurationStr: durationStr,
|
DurationStr: durationStr,
|
||||||
CoverURL: cover,
|
CoverURL: cover,
|
||||||
Quality: quality,
|
Quality: quality,
|
||||||
Format: format,
|
Format: strings.ToUpper(format),
|
||||||
Path: fPath,
|
Path: fPath,
|
||||||
|
Source: source,
|
||||||
}
|
}
|
||||||
|
|
||||||
if item.Format == "" || item.Format == "LOSSLESS" {
|
if item.Format == "" || item.Format == "LOSSLESS" {
|
||||||
@@ -544,7 +600,7 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
backend.AddHistoryItem(item, "SpotiFLAC")
|
backend.AddHistoryItem(item, "SpotiFLAC")
|
||||||
}(filename, req.TrackName, req.ArtistName, req.AlbumName, req.SpotifyID, req.CoverURL, req.AudioFormat)
|
}(filename, req.TrackName, req.ArtistName, req.AlbumName, req.SpotifyID, req.CoverURL, req.AudioFormat, req.Service)
|
||||||
}
|
}
|
||||||
|
|
||||||
return DownloadResponse{
|
return DownloadResponse{
|
||||||
@@ -569,6 +625,18 @@ func (a *App) OpenFolder(path string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) OpenConfigFolder() error {
|
||||||
|
homeDir, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get home directory: %v", err)
|
||||||
|
}
|
||||||
|
configDir := filepath.Join(homeDir, ".spotiflac")
|
||||||
|
if err := os.MkdirAll(configDir, 0755); err != nil {
|
||||||
|
return fmt.Errorf("failed to create config directory: %v", err)
|
||||||
|
}
|
||||||
|
return backend.OpenFolderInExplorer(configDir)
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) SelectFolder(defaultPath string) (string, error) {
|
func (a *App) SelectFolder(defaultPath string) (string, error) {
|
||||||
return backend.SelectFolderDialog(a.ctx, defaultPath)
|
return backend.SelectFolderDialog(a.ctx, defaultPath)
|
||||||
}
|
}
|
||||||
@@ -599,9 +667,9 @@ func (a *App) ClearAllDownloads() {
|
|||||||
backend.ClearAllDownloads()
|
backend.ClearAllDownloads()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) AddToDownloadQueue(isrc, trackName, artistName, albumName string) string {
|
func (a *App) AddToDownloadQueue(spotifyID, trackName, artistName, albumName string) string {
|
||||||
itemID := fmt.Sprintf("%s-%d", isrc, time.Now().UnixNano())
|
itemID := fmt.Sprintf("%s-%d", spotifyID, time.Now().UnixNano())
|
||||||
backend.AddToQueue(itemID, trackName, artistName, albumName, isrc)
|
backend.AddToQueue(itemID, trackName, artistName, albumName, "")
|
||||||
return itemID
|
return itemID
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -644,11 +712,9 @@ func (a *App) ExportFailedDownloads() (string, error) {
|
|||||||
failedItems = append(failedItems, line)
|
failedItems = append(failedItems, line)
|
||||||
failedItems = append(failedItems, fmt.Sprintf(" Error: %s", item.ErrorMessage))
|
failedItems = append(failedItems, fmt.Sprintf(" Error: %s", item.ErrorMessage))
|
||||||
|
|
||||||
if item.ISRC != "" {
|
if item.SpotifyID != "" {
|
||||||
failedItems = append(failedItems, fmt.Sprintf(" ID: %s", item.ISRC))
|
failedItems = append(failedItems, fmt.Sprintf(" ID: %s", item.SpotifyID))
|
||||||
if !strings.HasPrefix(item.ISRC, "http") {
|
failedItems = append(failedItems, fmt.Sprintf(" URL: https://open.spotify.com/track/%s", item.SpotifyID))
|
||||||
failedItems = append(failedItems, fmt.Sprintf(" URL: https://open.spotify.com/track/%s", item.ISRC))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
failedItems = append(failedItems, "")
|
failedItems = append(failedItems, "")
|
||||||
}
|
}
|
||||||
@@ -683,6 +749,52 @@ func (a *App) ExportFailedDownloads() (string, error) {
|
|||||||
return fmt.Sprintf("Successfully exported %d failed downloads to %s", count, path), nil
|
return fmt.Sprintf("Successfully exported %d failed downloads to %s", count, path), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) CheckAPIStatus(apiType string, apiURL string) bool {
|
||||||
|
var checkURL string
|
||||||
|
if apiType == "tidal" {
|
||||||
|
checkURL = fmt.Sprintf("%s/track/?id=441821360&quality=HI_RES_LOSSLESS", apiURL)
|
||||||
|
} else if apiType == "qobuz" {
|
||||||
|
checkURL = fmt.Sprintf("%s/api/stream?trackId=360735657&format_id=27", apiURL)
|
||||||
|
} else if apiType == "qbz" {
|
||||||
|
checkURL = fmt.Sprintf("%s/api/track/360735657?quality=27", apiURL)
|
||||||
|
} else if apiType == "amazon" {
|
||||||
|
checkURL = fmt.Sprintf("%s/status", apiURL)
|
||||||
|
} else {
|
||||||
|
checkURL = apiURL
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 15 * time.Second}
|
||||||
|
req, err := http.NewRequest("GET", checkURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36")
|
||||||
|
|
||||||
|
maxRetries := 3
|
||||||
|
for i := 0; i < maxRetries; i++ {
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err == nil {
|
||||||
|
statusCode := resp.StatusCode
|
||||||
|
if apiType == "amazon" && statusCode == 200 {
|
||||||
|
body, readErr := io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
if readErr == nil && strings.Contains(string(body), `"amazonMusic":"up"`) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
resp.Body.Close()
|
||||||
|
if statusCode == 200 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if i < maxRetries-1 {
|
||||||
|
time.Sleep(1 * time.Second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) Quit() {
|
func (a *App) Quit() {
|
||||||
|
|
||||||
panic("quit")
|
panic("quit")
|
||||||
@@ -720,46 +832,28 @@ func (a *App) ClearFetchHistoryByType(itemType string) error {
|
|||||||
return backend.ClearFetchHistoryByType(itemType, "SpotiFLAC")
|
return backend.ClearFetchHistoryByType(itemType, "SpotiFLAC")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) AnalyzeTrack(filePath string) (string, error) {
|
func (a *App) SaveSpectrumImage(audioFilePath string, base64Data string) (string, error) {
|
||||||
if filePath == "" {
|
if audioFilePath == "" || base64Data == "" {
|
||||||
return "", fmt.Errorf("file path is required")
|
return "", fmt.Errorf("file path and image data are required")
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := backend.AnalyzeTrack(filePath)
|
base64Data = strings.TrimPrefix(base64Data, "data:image/png;base64,")
|
||||||
|
|
||||||
|
data, err := base64.StdEncoding.DecodeString(base64Data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to analyze track: %v", err)
|
return "", fmt.Errorf("failed to decode base64 image: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
jsonData, err := json.Marshal(result)
|
ext := filepath.Ext(audioFilePath)
|
||||||
|
baseName := strings.TrimSuffix(filepath.Base(audioFilePath), ext)
|
||||||
|
outPath := filepath.Join(filepath.Dir(audioFilePath), baseName+".png")
|
||||||
|
|
||||||
|
err = os.WriteFile(outPath, data, 0644)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to encode response: %v", err)
|
return "", fmt.Errorf("failed to save image to disk: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return string(jsonData), nil
|
return outPath, nil
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) AnalyzeMultipleTracks(filePaths []string) (string, error) {
|
|
||||||
if len(filePaths) == 0 {
|
|
||||||
return "", fmt.Errorf("at least one file path is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
results := make([]*backend.AnalysisResult, 0, len(filePaths))
|
|
||||||
|
|
||||||
for _, filePath := range filePaths {
|
|
||||||
result, err := backend.AnalyzeTrack(filePath)
|
|
||||||
if err != nil {
|
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
results = append(results, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
jsonData, err := json.Marshal(results)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to encode response: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return string(jsonData), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type LyricsDownloadRequest struct {
|
type LyricsDownloadRequest struct {
|
||||||
@@ -979,13 +1073,13 @@ func (a *App) DownloadAvatar(req AvatarDownloadRequest) (backend.AvatarDownloadR
|
|||||||
return *resp, nil
|
return *resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) CheckTrackAvailability(spotifyTrackID string, isrc string) (string, error) {
|
func (a *App) CheckTrackAvailability(spotifyTrackID string) (string, error) {
|
||||||
if spotifyTrackID == "" {
|
if spotifyTrackID == "" {
|
||||||
return "", fmt.Errorf("spotify track ID is required")
|
return "", fmt.Errorf("spotify track ID is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
client := backend.NewSongLinkClient()
|
client := backend.NewSongLinkClient()
|
||||||
availability, err := client.CheckTrackAvailability(spotifyTrackID, isrc)
|
availability, err := client.CheckTrackAvailability(spotifyTrackID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -1006,10 +1100,6 @@ func (a *App) IsFFprobeInstalled() (bool, error) {
|
|||||||
return backend.IsFFprobeInstalled()
|
return backend.IsFFprobeInstalled()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) GetFFmpegPath() (string, error) {
|
|
||||||
return backend.GetFFmpegPath()
|
|
||||||
}
|
|
||||||
|
|
||||||
type DownloadFFmpegRequest struct{}
|
type DownloadFFmpegRequest struct{}
|
||||||
|
|
||||||
type DownloadFFmpegResponse struct {
|
type DownloadFFmpegResponse struct {
|
||||||
@@ -1038,6 +1128,41 @@ func (a *App) DownloadFFmpeg() DownloadFFmpegResponse {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) GetBrewPath() string {
|
||||||
|
return backend.GetBrewPath()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) IsBrewFFmpegInstalled() (bool, error) {
|
||||||
|
return backend.IsBrewFFmpegInstalled()
|
||||||
|
}
|
||||||
|
|
||||||
|
type InstallFFmpegWithBrewResponse struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) InstallFFmpegWithBrew() InstallFFmpegWithBrewResponse {
|
||||||
|
runtime.EventsEmit(a.ctx, "ffmpeg:status", "Installing FFmpeg via Homebrew...")
|
||||||
|
err := backend.InstallFFmpegWithBrew(func(progress int, status string) {
|
||||||
|
runtime.EventsEmit(a.ctx, "ffmpeg:progress", progress)
|
||||||
|
runtime.EventsEmit(a.ctx, "ffmpeg:status", status)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
runtime.EventsEmit(a.ctx, "ffmpeg:status", "failed")
|
||||||
|
return InstallFFmpegWithBrewResponse{
|
||||||
|
Success: false,
|
||||||
|
Error: err.Error(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
runtime.EventsEmit(a.ctx, "ffmpeg:status", "completed")
|
||||||
|
return InstallFFmpegWithBrewResponse{
|
||||||
|
Success: true,
|
||||||
|
Message: "FFmpeg installed successfully via Homebrew",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type ConvertAudioRequest struct {
|
type ConvertAudioRequest struct {
|
||||||
InputFiles []string `json:"input_files"`
|
InputFiles []string `json:"input_files"`
|
||||||
OutputFormat string `json:"output_format"`
|
OutputFormat string `json:"output_format"`
|
||||||
@@ -1055,6 +1180,21 @@ func (a *App) ConvertAudio(req ConvertAudioRequest) ([]backend.ConvertAudioResul
|
|||||||
return backend.ConvertAudio(backendReq)
|
return backend.ConvertAudio(backendReq)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ResampleAudioRequest struct {
|
||||||
|
InputFiles []string `json:"input_files"`
|
||||||
|
SampleRate string `json:"sample_rate"`
|
||||||
|
BitDepth string `json:"bit_depth"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) ResampleAudio(req ResampleAudioRequest) ([]backend.ResampleResult, error) {
|
||||||
|
backendReq := backend.ResampleRequest{
|
||||||
|
InputFiles: req.InputFiles,
|
||||||
|
SampleRate: req.SampleRate,
|
||||||
|
BitDepth: req.BitDepth,
|
||||||
|
}
|
||||||
|
return backend.ResampleAudio(backendReq)
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) SelectAudioFiles() ([]string, error) {
|
func (a *App) SelectAudioFiles() ([]string, error) {
|
||||||
files, err := backend.SelectMultipleFiles(a.ctx)
|
files, err := backend.SelectMultipleFiles(a.ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1063,6 +1203,10 @@ func (a *App) SelectAudioFiles() ([]string, error) {
|
|||||||
return files, nil
|
return files, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) GetFlacInfoBatch(paths []string) []backend.FlacInfo {
|
||||||
|
return backend.GetFlacInfoBatch(paths)
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) GetFileSizes(files []string) map[string]int64 {
|
func (a *App) GetFileSizes(files []string) map[string]int64 {
|
||||||
return backend.GetFileSizes(files)
|
return backend.GetFileSizes(files)
|
||||||
}
|
}
|
||||||
@@ -1104,6 +1248,15 @@ func (a *App) ReadTextFile(filePath string) (string, error) {
|
|||||||
return string(content), nil
|
return string(content), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) ReadFileAsBase64(filePath string) (string, error) {
|
||||||
|
content, err := os.ReadFile(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return base64.StdEncoding.EncodeToString(content), nil
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) RenameFileTo(oldPath, newName string) error {
|
func (a *App) RenameFileTo(oldPath, newName string) error {
|
||||||
dir := filepath.Dir(oldPath)
|
dir := filepath.Dir(oldPath)
|
||||||
ext := filepath.Ext(oldPath)
|
ext := filepath.Ext(oldPath)
|
||||||
@@ -1111,23 +1264,6 @@ func (a *App) RenameFileTo(oldPath, newName string) error {
|
|||||||
return os.Rename(oldPath, newPath)
|
return os.Rename(oldPath, newPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) UploadImage(filePath string) (string, error) {
|
|
||||||
return backend.UploadToSendNow(filePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) UploadImageBytes(filename string, base64Data string) (string, error) {
|
|
||||||
|
|
||||||
if idx := strings.Index(base64Data, ","); idx != -1 {
|
|
||||||
base64Data = base64Data[idx+1:]
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := base64.StdEncoding.DecodeString(base64Data)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to decode base64: %v", err)
|
|
||||||
}
|
|
||||||
return backend.UploadBytesToSendNow(filename, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) SelectImageVideo() ([]string, error) {
|
func (a *App) SelectImageVideo() ([]string, error) {
|
||||||
return backend.SelectImageVideoDialog(a.ctx)
|
return backend.SelectImageVideoDialog(a.ctx)
|
||||||
}
|
}
|
||||||
@@ -1393,10 +1529,6 @@ func (a *App) CheckFFmpegInstalled() (bool, error) {
|
|||||||
return backend.IsFFmpegInstalled()
|
return backend.IsFFmpegInstalled()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) GetOSInfo() (string, error) {
|
|
||||||
return backend.GetOSInfo()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) CreateM3U8File(m3u8Name string, outputDir string, filePaths []string) error {
|
func (a *App) CreateM3U8File(m3u8Name string, outputDir string, filePaths []string) error {
|
||||||
if len(filePaths) == 0 {
|
if len(filePaths) == 0 {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
+73
-69
@@ -1,12 +1,10 @@
|
|||||||
package backend
|
package backend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -20,12 +18,6 @@ type AmazonDownloader struct {
|
|||||||
regions []string
|
regions []string
|
||||||
}
|
}
|
||||||
|
|
||||||
type SongLinkResponse struct {
|
|
||||||
LinksByPlatform map[string]struct {
|
|
||||||
URL string `json:"url"`
|
|
||||||
} `json:"linksByPlatform"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type AmazonStreamResponse struct {
|
type AmazonStreamResponse struct {
|
||||||
StreamURL string `json:"streamUrl"`
|
StreamURL string `json:"streamUrl"`
|
||||||
DecryptionKey string `json:"decryptionKey"`
|
DecryptionKey string `json:"decryptionKey"`
|
||||||
@@ -41,66 +33,17 @@ func NewAmazonDownloader() *AmazonDownloader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (string, error) {
|
func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (string, error) {
|
||||||
|
|
||||||
spotifyBase := "https://open.spotify.com/track/"
|
|
||||||
spotifyURL := fmt.Sprintf("%s%s", spotifyBase, spotifyTrackID)
|
|
||||||
|
|
||||||
apiBase := "https://api.song.link/v1-alpha.1/links?url="
|
|
||||||
apiURL := fmt.Sprintf("%s%s", apiBase, url.QueryEscape(spotifyURL))
|
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", apiURL, nil)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to create request: %w", err)
|
|
||||||
}
|
|
||||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36")
|
|
||||||
|
|
||||||
fmt.Println("Getting Amazon URL...")
|
fmt.Println("Getting Amazon URL...")
|
||||||
|
client := NewSongLinkClient()
|
||||||
resp, err := a.client.Do(req)
|
urls, err := client.GetAllURLsFromSpotify(spotifyTrackID, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to get Amazon URL: %w", err)
|
return "", fmt.Errorf("failed to get Amazon URL: %w", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
amazonURL := normalizeAmazonMusicURL(urls.AmazonURL)
|
||||||
return "", fmt.Errorf("API returned status %d", resp.StatusCode)
|
if amazonURL == "" {
|
||||||
}
|
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to read response body: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(body) == 0 {
|
|
||||||
return "", fmt.Errorf("API returned empty response")
|
|
||||||
}
|
|
||||||
|
|
||||||
var songLinkResp SongLinkResponse
|
|
||||||
if err := json.Unmarshal(body, &songLinkResp); err != nil {
|
|
||||||
|
|
||||||
bodyStr := string(body)
|
|
||||||
if len(bodyStr) > 200 {
|
|
||||||
bodyStr = bodyStr[:200] + "..."
|
|
||||||
}
|
|
||||||
return "", fmt.Errorf("failed to decode response: %w (response: %s)", err, bodyStr)
|
|
||||||
}
|
|
||||||
|
|
||||||
amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]
|
|
||||||
if !ok || amazonLink.URL == "" {
|
|
||||||
return "", fmt.Errorf("amazon Music link not found")
|
return "", fmt.Errorf("amazon Music link not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
amazonURL := amazonLink.URL
|
|
||||||
|
|
||||||
if strings.Contains(amazonURL, "trackAsin=") {
|
|
||||||
parts := strings.Split(amazonURL, "trackAsin=")
|
|
||||||
if len(parts) > 1 {
|
|
||||||
trackAsin := strings.Split(parts[1], "&")[0]
|
|
||||||
musicBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9tdXNpYy5hbWF6b24uY29tL3RyYWNrcy8=")
|
|
||||||
amazonURL = fmt.Sprintf("%s%s?musicTerritory=US", string(musicBase), trackAsin)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("Found Amazon URL: %s\n", amazonURL)
|
fmt.Printf("Found Amazon URL: %s\n", amazonURL)
|
||||||
return amazonURL, nil
|
return amazonURL, nil
|
||||||
}
|
}
|
||||||
@@ -113,12 +56,12 @@ func (a *AmazonDownloader) DownloadFromAfkarXYZ(amazonURL, outputDir, quality st
|
|||||||
return "", fmt.Errorf("failed to extract ASIN from URL: %s", amazonURL)
|
return "", fmt.Errorf("failed to extract ASIN from URL: %s", amazonURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
apiURL := fmt.Sprintf("https://amazon.afkarxyz.fun/api/track/%s", asin)
|
apiURL := fmt.Sprintf("https://amzn.afkarxyz.qzz.io/api/track/%s", asin)
|
||||||
req, err := http.NewRequest("GET", apiURL, nil)
|
req, err := http.NewRequest("GET", apiURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36")
|
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36")
|
||||||
|
|
||||||
fmt.Printf("Fetching from Amazon API (ASIN: %s)...\n", asin)
|
fmt.Printf("Fetching from Amazon API (ASIN: %s)...\n", asin)
|
||||||
resp, err := a.client.Do(req)
|
resp, err := a.client.Do(req)
|
||||||
@@ -156,7 +99,7 @@ func (a *AmazonDownloader) DownloadFromAfkarXYZ(amazonURL, outputDir, quality st
|
|||||||
defer out.Close()
|
defer out.Close()
|
||||||
|
|
||||||
dlReq, _ := http.NewRequest("GET", downloadURL, nil)
|
dlReq, _ := http.NewRequest("GET", downloadURL, nil)
|
||||||
dlReq.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36")
|
dlReq.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36")
|
||||||
|
|
||||||
dlResp, err := a.client.Do(dlReq)
|
dlResp, err := a.client.Do(dlReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -261,7 +204,7 @@ func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir, quality str
|
|||||||
return a.DownloadFromAfkarXYZ(amazonURL, outputDir, quality)
|
return a.DownloadFromAfkarXYZ(amazonURL, outputDir, quality)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filenameFormat, playlistName, playlistOwner string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover bool, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string) (string, error) {
|
func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filenameFormat, playlistName, playlistOwner string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover bool, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) {
|
||||||
|
|
||||||
if outputDir != "." {
|
if outputDir != "." {
|
||||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||||
@@ -270,7 +213,13 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename
|
|||||||
}
|
}
|
||||||
|
|
||||||
if spotifyTrackName != "" && spotifyArtistName != "" {
|
if spotifyTrackName != "" && spotifyArtistName != "" {
|
||||||
expectedFilename := BuildExpectedFilename(spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, filenameFormat, playlistName, playlistOwner, includeTrackNumber, position, spotifyDiscNumber, false)
|
filenameArtist := spotifyArtistName
|
||||||
|
filenameAlbumArtist := spotifyAlbumArtist
|
||||||
|
if useFirstArtistOnly {
|
||||||
|
filenameArtist = GetFirstArtist(spotifyArtistName)
|
||||||
|
filenameAlbumArtist = GetFirstArtist(spotifyAlbumArtist)
|
||||||
|
}
|
||||||
|
expectedFilename := BuildExpectedFilename(spotifyTrackName, filenameArtist, spotifyAlbumName, filenameAlbumArtist, spotifyReleaseDate, filenameFormat, playlistName, playlistOwner, includeTrackNumber, position, spotifyDiscNumber, false)
|
||||||
expectedPath := filepath.Join(outputDir, expectedFilename)
|
expectedPath := filepath.Join(outputDir, expectedFilename)
|
||||||
|
|
||||||
if fileInfo, err := os.Stat(expectedPath); err == nil && fileInfo.Size() > 0 {
|
if fileInfo, err := os.Stat(expectedPath); err == nil && fileInfo.Size() > 0 {
|
||||||
@@ -279,6 +228,42 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type mbResult struct {
|
||||||
|
ISRC string
|
||||||
|
Metadata Metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
metaChan := make(chan mbResult, 1)
|
||||||
|
if embedGenre && spotifyURL != "" {
|
||||||
|
go func() {
|
||||||
|
res := mbResult{}
|
||||||
|
var isrc string
|
||||||
|
parts := strings.Split(spotifyURL, "/")
|
||||||
|
if len(parts) > 0 {
|
||||||
|
sID := strings.Split(parts[len(parts)-1], "?")[0]
|
||||||
|
if sID != "" {
|
||||||
|
client := NewSongLinkClient()
|
||||||
|
if val, err := client.GetISRC(sID); err == nil {
|
||||||
|
isrc = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res.ISRC = isrc
|
||||||
|
if isrc != "" {
|
||||||
|
fmt.Println("Fetching MusicBrainz metadata...")
|
||||||
|
if fetchedMeta, err := FetchMusicBrainzMetadata(isrc, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useSingleGenre, embedGenre); err == nil {
|
||||||
|
res.Metadata = fetchedMeta
|
||||||
|
fmt.Println("✓ MusicBrainz metadata fetched")
|
||||||
|
} else {
|
||||||
|
fmt.Printf("Warning: Failed to fetch MusicBrainz metadata: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
metaChan <- res
|
||||||
|
}()
|
||||||
|
} else {
|
||||||
|
close(metaChan)
|
||||||
|
}
|
||||||
|
|
||||||
fmt.Printf("Using Amazon URL: %s\n", amazonURL)
|
fmt.Printf("Using Amazon URL: %s\n", amazonURL)
|
||||||
|
|
||||||
filePath, err := a.DownloadFromService(amazonURL, outputDir, quality)
|
filePath, err := a.DownloadFromService(amazonURL, outputDir, quality)
|
||||||
@@ -286,14 +271,28 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var isrc string
|
||||||
|
var mbMeta Metadata
|
||||||
|
if spotifyURL != "" {
|
||||||
|
result := <-metaChan
|
||||||
|
isrc = result.ISRC
|
||||||
|
mbMeta = result.Metadata
|
||||||
|
}
|
||||||
|
|
||||||
originalFileDir := filepath.Dir(filePath)
|
originalFileDir := filepath.Dir(filePath)
|
||||||
originalFileBase := strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
|
originalFileBase := strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
|
||||||
|
|
||||||
if spotifyTrackName != "" && spotifyArtistName != "" {
|
if spotifyTrackName != "" && spotifyArtistName != "" {
|
||||||
safeArtist := sanitizeFilename(spotifyArtistName)
|
safeArtist := sanitizeFilename(spotifyArtistName)
|
||||||
|
safeAlbumArtist := sanitizeFilename(spotifyAlbumArtist)
|
||||||
|
|
||||||
|
if useFirstArtistOnly {
|
||||||
|
safeArtist = sanitizeFilename(GetFirstArtist(spotifyArtistName))
|
||||||
|
safeAlbumArtist = sanitizeFilename(GetFirstArtist(spotifyAlbumArtist))
|
||||||
|
}
|
||||||
|
|
||||||
safeTitle := sanitizeFilename(spotifyTrackName)
|
safeTitle := sanitizeFilename(spotifyTrackName)
|
||||||
safeAlbum := sanitizeFilename(spotifyAlbumName)
|
safeAlbum := sanitizeFilename(spotifyAlbumName)
|
||||||
safeAlbumArtist := sanitizeFilename(spotifyAlbumArtist)
|
|
||||||
|
|
||||||
year := ""
|
year := ""
|
||||||
if len(spotifyReleaseDate) >= 4 {
|
if len(spotifyReleaseDate) >= 4 {
|
||||||
@@ -309,6 +308,7 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename
|
|||||||
newFilename = strings.ReplaceAll(newFilename, "{album}", safeAlbum)
|
newFilename = strings.ReplaceAll(newFilename, "{album}", safeAlbum)
|
||||||
newFilename = strings.ReplaceAll(newFilename, "{album_artist}", safeAlbumArtist)
|
newFilename = strings.ReplaceAll(newFilename, "{album_artist}", safeAlbumArtist)
|
||||||
newFilename = strings.ReplaceAll(newFilename, "{year}", year)
|
newFilename = strings.ReplaceAll(newFilename, "{year}", year)
|
||||||
|
newFilename = strings.ReplaceAll(newFilename, "{date}", SanitizeFilename(spotifyReleaseDate))
|
||||||
|
|
||||||
if spotifyDiscNumber > 0 {
|
if spotifyDiscNumber > 0 {
|
||||||
newFilename = strings.ReplaceAll(newFilename, "{disc}", fmt.Sprintf("%d", spotifyDiscNumber))
|
newFilename = strings.ReplaceAll(newFilename, "{disc}", fmt.Sprintf("%d", spotifyDiscNumber))
|
||||||
@@ -390,6 +390,8 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename
|
|||||||
Copyright: spotifyCopyright,
|
Copyright: spotifyCopyright,
|
||||||
Publisher: spotifyPublisher,
|
Publisher: spotifyPublisher,
|
||||||
Description: "https://github.com/afkarxyz/SpotiFLAC",
|
Description: "https://github.com/afkarxyz/SpotiFLAC",
|
||||||
|
ISRC: isrc,
|
||||||
|
Genre: mbMeta.Genre,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := EmbedMetadataToConvertedFile(filePath, metadata, coverPath); err != nil {
|
if err := EmbedMetadataToConvertedFile(filePath, metadata, coverPath); err != nil {
|
||||||
@@ -415,12 +417,14 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename
|
|||||||
return filePath, nil
|
return filePath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *AmazonDownloader) DownloadBySpotifyID(spotifyTrackID, outputDir, quality, filenameFormat, playlistName, playlistOwner string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover bool, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string) (string, error) {
|
func (a *AmazonDownloader) DownloadBySpotifyID(spotifyTrackID, outputDir, quality, filenameFormat, playlistName, playlistOwner string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover bool, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string,
|
||||||
|
useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool,
|
||||||
|
) (string, error) {
|
||||||
|
|
||||||
amazonURL, err := a.GetAmazonURLFromSpotify(spotifyTrackID)
|
amazonURL, err := a.GetAmazonURLFromSpotify(spotifyTrackID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return a.DownloadByURL(amazonURL, outputDir, quality, filenameFormat, playlistName, playlistOwner, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, embedMaxQualityCover, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyURL)
|
return a.DownloadByURL(amazonURL, outputDir, quality, filenameFormat, playlistName, playlistOwner, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, embedMaxQualityCover, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyURL, useFirstArtistOnly, useSingleGenre, embedGenre)
|
||||||
}
|
}
|
||||||
|
|||||||
+91
-183
@@ -2,165 +2,26 @@ package backend
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"github.com/go-flac/go-flac"
|
"strconv"
|
||||||
mewflac "github.com/mewkiz/flac"
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type AnalysisResult struct {
|
type AnalysisResult struct {
|
||||||
FilePath string `json:"file_path"`
|
FilePath string `json:"file_path"`
|
||||||
FileSize int64 `json:"file_size"`
|
FileSize int64 `json:"file_size"`
|
||||||
SampleRate uint32 `json:"sample_rate"`
|
SampleRate uint32 `json:"sample_rate"`
|
||||||
Channels uint8 `json:"channels"`
|
Channels uint8 `json:"channels"`
|
||||||
BitsPerSample uint8 `json:"bits_per_sample"`
|
BitsPerSample uint8 `json:"bits_per_sample"`
|
||||||
TotalSamples uint64 `json:"total_samples"`
|
TotalSamples uint64 `json:"total_samples"`
|
||||||
Duration float64 `json:"duration"`
|
Duration float64 `json:"duration"`
|
||||||
BitDepth string `json:"bit_depth"`
|
Bitrate int `json:"bit_rate"`
|
||||||
DynamicRange float64 `json:"dynamic_range"`
|
BitDepth string `json:"bit_depth"`
|
||||||
PeakAmplitude float64 `json:"peak_amplitude"`
|
DynamicRange float64 `json:"dynamic_range"`
|
||||||
RMSLevel float64 `json:"rms_level"`
|
PeakAmplitude float64 `json:"peak_amplitude"`
|
||||||
Spectrum *SpectrumData `json:"spectrum,omitempty"`
|
RMSLevel float64 `json:"rms_level"`
|
||||||
}
|
|
||||||
|
|
||||||
func AnalyzeTrack(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.Channels = ((data[12] >> 1) & 0x07) + 1
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
spectrum, err := AnalyzeSpectrum(filepath)
|
|
||||||
if err != nil {
|
|
||||||
|
|
||||||
fmt.Printf("Warning: failed to analyze spectrum: %v\n", err)
|
|
||||||
} else {
|
|
||||||
result.Spectrum = spectrum
|
|
||||||
|
|
||||||
calculateRealAudioMetrics(result, filepath)
|
|
||||||
}
|
|
||||||
|
|
||||||
result.BitDepth = fmt.Sprintf("%d-bit", result.BitsPerSample)
|
|
||||||
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func calculateRealAudioMetrics(result *AnalysisResult, filepath string) {
|
|
||||||
|
|
||||||
samples, err := decodeFLACForMetrics(filepath)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var peak float64
|
|
||||||
var sumSquares float64
|
|
||||||
|
|
||||||
for _, sample := range samples {
|
|
||||||
absVal := sample
|
|
||||||
if absVal < 0 {
|
|
||||||
absVal = -absVal
|
|
||||||
}
|
|
||||||
if absVal > peak {
|
|
||||||
peak = absVal
|
|
||||||
}
|
|
||||||
sumSquares += sample * sample
|
|
||||||
}
|
|
||||||
|
|
||||||
peakDB := 20.0 * math.Log10(peak)
|
|
||||||
result.PeakAmplitude = peakDB
|
|
||||||
|
|
||||||
rms := math.Sqrt(sumSquares / float64(len(samples)))
|
|
||||||
rmsDB := 20.0 * math.Log10(rms)
|
|
||||||
result.RMSLevel = rmsDB
|
|
||||||
|
|
||||||
result.DynamicRange = peakDB - rmsDB
|
|
||||||
}
|
|
||||||
|
|
||||||
func decodeFLACForMetrics(filepath string) ([]float64, error) {
|
|
||||||
stream, err := mewflac.ParseFile(filepath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer stream.Close()
|
|
||||||
|
|
||||||
maxSamples := 10000000
|
|
||||||
samples := make([]float64, 0, maxSamples)
|
|
||||||
|
|
||||||
for {
|
|
||||||
frame, err := stream.ParseNext()
|
|
||||||
if err != nil {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
var channelSamples []int32
|
|
||||||
if len(frame.Subframes) > 0 {
|
|
||||||
channelSamples = frame.Subframes[0].Samples
|
|
||||||
}
|
|
||||||
|
|
||||||
maxVal := float64(int64(1) << (stream.Info.BitsPerSample - 1))
|
|
||||||
for _, sample := range channelSamples {
|
|
||||||
if len(samples) >= maxSamples {
|
|
||||||
return samples, nil
|
|
||||||
}
|
|
||||||
normalized := float64(sample) / maxVal
|
|
||||||
samples = append(samples, normalized)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(samples) >= maxSamples {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return samples, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetFileSize(filepath string) (int64, error) {
|
|
||||||
info, err := os.Stat(filepath)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return info.Size(), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetTrackMetadata(filepath string) (*AnalysisResult, error) {
|
func GetTrackMetadata(filepath string) (*AnalysisResult, error) {
|
||||||
@@ -168,40 +29,87 @@ func GetTrackMetadata(filepath string) (*AnalysisResult, error) {
|
|||||||
return nil, fmt.Errorf("file does not exist: %s", filepath)
|
return nil, fmt.Errorf("file does not exist: %s", filepath)
|
||||||
}
|
}
|
||||||
|
|
||||||
fileInfo, err := os.Stat(filepath)
|
return GetMetadataWithFFprobe(filepath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetMetadataWithFFprobe(filePath string) (*AnalysisResult, error) {
|
||||||
|
ffprobePath, err := GetFFprobePath()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get file info: %w", err)
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
f, err := flac.ParseFile(filepath)
|
for i := 0; i < 5; i++ {
|
||||||
|
if f, err := os.Open(filePath); err == nil {
|
||||||
|
f.Close()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
args := []string{
|
||||||
|
"-v", "error",
|
||||||
|
"-select_streams", "a:0",
|
||||||
|
"-show_entries", "stream=sample_rate,channels,bits_per_raw_sample,bits_per_sample,duration,bit_rate",
|
||||||
|
"-of", "default=noprint_wrappers=0",
|
||||||
|
filePath,
|
||||||
|
}
|
||||||
|
cmd := exec.Command(ffprobePath, args...)
|
||||||
|
setHideWindow(cmd)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to parse FLAC file: %w", err)
|
return nil, fmt.Errorf("ffprobe failed: %v - %s", err, string(output))
|
||||||
}
|
}
|
||||||
|
|
||||||
result := &AnalysisResult{
|
infoMap := make(map[string]string)
|
||||||
FilePath: filepath,
|
lines := strings.Split(string(output), "\n")
|
||||||
FileSize: fileInfo.Size(),
|
for _, line := range lines {
|
||||||
}
|
if strings.Contains(line, "=") {
|
||||||
|
parts := strings.SplitN(line, "=", 2)
|
||||||
if len(f.Meta) > 0 {
|
infoMap[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1])
|
||||||
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
|
res := &AnalysisResult{
|
||||||
|
FilePath: filePath,
|
||||||
|
}
|
||||||
|
|
||||||
|
if info, err := os.Stat(filePath); err == nil {
|
||||||
|
res.FileSize = info.Size()
|
||||||
|
}
|
||||||
|
|
||||||
|
if val, ok := infoMap["sample_rate"]; ok {
|
||||||
|
s, _ := strconv.Atoi(val)
|
||||||
|
res.SampleRate = uint32(s)
|
||||||
|
}
|
||||||
|
if val, ok := infoMap["channels"]; ok {
|
||||||
|
c, _ := strconv.Atoi(val)
|
||||||
|
res.Channels = uint8(c)
|
||||||
|
}
|
||||||
|
if val, ok := infoMap["duration"]; ok {
|
||||||
|
d, _ := strconv.ParseFloat(val, 64)
|
||||||
|
res.Duration = d
|
||||||
|
}
|
||||||
|
if val, ok := infoMap["bit_rate"]; ok && val != "N/A" {
|
||||||
|
br, _ := strconv.Atoi(val)
|
||||||
|
res.Bitrate = br
|
||||||
|
}
|
||||||
|
|
||||||
|
bits := 0
|
||||||
|
if val, ok := infoMap["bits_per_raw_sample"]; ok && val != "N/A" {
|
||||||
|
bits, _ = strconv.Atoi(val)
|
||||||
|
}
|
||||||
|
if bits == 0 {
|
||||||
|
if val, ok := infoMap["bits_per_sample"]; ok && val != "N/A" {
|
||||||
|
bits, _ = strconv.Atoi(val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.BitsPerSample = uint8(bits)
|
||||||
|
if bits > 0 {
|
||||||
|
res.BitDepth = fmt.Sprintf("%d-bit", bits)
|
||||||
|
} else {
|
||||||
|
res.BitDepth = "Unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
return res, nil
|
||||||
}
|
}
|
||||||
|
|||||||
+71
-1
@@ -1,7 +1,10 @@
|
|||||||
package backend
|
package backend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"image/png"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
@@ -9,6 +12,9 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
xdraw "golang.org/x/image/draw"
|
||||||
|
_ "image/jpeg"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -83,6 +89,7 @@ func buildCoverFilename(trackName, artistName, albumName, albumArtist, releaseDa
|
|||||||
filename = strings.ReplaceAll(filename, "{album}", safeAlbum)
|
filename = strings.ReplaceAll(filename, "{album}", safeAlbum)
|
||||||
filename = strings.ReplaceAll(filename, "{album_artist}", safeAlbumArtist)
|
filename = strings.ReplaceAll(filename, "{album_artist}", safeAlbumArtist)
|
||||||
filename = strings.ReplaceAll(filename, "{year}", year)
|
filename = strings.ReplaceAll(filename, "{year}", year)
|
||||||
|
filename = strings.ReplaceAll(filename, "{date}", sanitizeFilename(releaseDate))
|
||||||
|
|
||||||
if discNumber > 0 {
|
if discNumber > 0 {
|
||||||
filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber))
|
filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber))
|
||||||
@@ -116,7 +123,7 @@ func buildCoverFilename(trackName, artistName, albumName, albumArtist, releaseDa
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return filename + ".cover.jpg"
|
return filename + ".jpg"
|
||||||
}
|
}
|
||||||
|
|
||||||
func convertSmallToMedium(imageURL string) string {
|
func convertSmallToMedium(imageURL string) string {
|
||||||
@@ -169,6 +176,69 @@ func (c *CoverClient) DownloadCoverToPath(coverURL, outputPath string, embedMaxQ
|
|||||||
return nil
|
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) {
|
func (c *CoverClient) DownloadCover(req CoverDownloadRequest) (*CoverDownloadResponse, error) {
|
||||||
if req.CoverURL == "" {
|
if req.CoverURL == "" {
|
||||||
return &CoverDownloadResponse{
|
return &CoverDownloadResponse{
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
+150
-45
@@ -3,7 +3,7 @@ package backend
|
|||||||
import (
|
import (
|
||||||
"archive/tar"
|
"archive/tar"
|
||||||
"archive/zip"
|
"archive/zip"
|
||||||
"encoding/base64"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -16,16 +16,9 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/ulikunitz/xz"
|
"github.com/ulikunitz/xz"
|
||||||
|
"golang.org/x/text/unicode/norm"
|
||||||
)
|
)
|
||||||
|
|
||||||
func decodeBase64(encoded string) (string, error) {
|
|
||||||
decoded, err := base64.StdEncoding.DecodeString(encoded)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return string(decoded), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func ValidateExecutable(path string) error {
|
func ValidateExecutable(path string) error {
|
||||||
cleanedPath := filepath.Clean(path)
|
cleanedPath := filepath.Clean(path)
|
||||||
if cleanedPath == "" {
|
if cleanedPath == "" {
|
||||||
@@ -65,13 +58,6 @@ func ValidateExecutable(path string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
|
||||||
ffmpegWindowsURL = "aHR0cHM6Ly9naXRodWIuY29tL0J0Yk4vRkZtcGVnLUJ1aWxkcy9yZWxlYXNlcy9kb3dubG9hZC9sYXRlc3QvZmZtcGVnLW1hc3Rlci1sYXRlc3Qtd2luNjQtZ3BsLnppcA=="
|
|
||||||
ffmpegLinuxURL = "aHR0cHM6Ly9naXRodWIuY29tL0J0Yk4vRkZtcGVnLUJ1aWxkcy9yZWxlYXNlcy9kb3dubG9hZC9sYXRlc3QvZmZtcGVnLW1hc3Rlci1sYXRlc3QtbGludXg2NC1ncGwudGFyLnh6"
|
|
||||||
ffmpegMacOSURL = "aHR0cHM6Ly9ldmVybWVldC5jeC9mZm1wZWcvZ2V0cmVsZWFzZS96aXA="
|
|
||||||
ffprobeMacOSURL = "aHR0cHM6Ly9ldmVybWVldC5jeC9mZm1wZWcvZ2V0cmVsZWFzZS9mZnByb2JlL3ppcA=="
|
|
||||||
)
|
|
||||||
|
|
||||||
func GetFFmpegDir() (string, error) {
|
func GetFFmpegDir() (string, error) {
|
||||||
homeDir, err := os.UserHomeDir()
|
homeDir, err := os.UserHomeDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -96,6 +82,28 @@ func GetFFmpegPath() (string, error) {
|
|||||||
return localPath, nil
|
return localPath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if runtime.GOOS == "darwin" && runtime.GOARCH == "arm64" {
|
||||||
|
homebrewPath := "/opt/homebrew/bin/" + ffmpegName
|
||||||
|
if _, err := os.Stat(homebrewPath); err == nil {
|
||||||
|
return homebrewPath, nil
|
||||||
|
}
|
||||||
|
} else if runtime.GOOS == "darwin" && runtime.GOARCH == "amd64" {
|
||||||
|
homebrewPath := "/usr/local/bin/" + ffmpegName
|
||||||
|
if _, err := os.Stat(homebrewPath); err == nil {
|
||||||
|
return homebrewPath, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if runtime.GOOS != "windows" {
|
||||||
|
path, err := exec.Command("which", ffmpegName).Output()
|
||||||
|
if err == nil {
|
||||||
|
trimmed := strings.TrimSpace(string(path))
|
||||||
|
if trimmed != "" {
|
||||||
|
return trimmed, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
path, err := exec.LookPath(ffmpegName)
|
path, err := exec.LookPath(ffmpegName)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return path, nil
|
return path, nil
|
||||||
@@ -120,6 +128,28 @@ func GetFFprobePath() (string, error) {
|
|||||||
return localPath, nil
|
return localPath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if runtime.GOOS == "darwin" && runtime.GOARCH == "arm64" {
|
||||||
|
homebrewPath := "/opt/homebrew/bin/" + ffprobeName
|
||||||
|
if _, err := os.Stat(homebrewPath); err == nil {
|
||||||
|
return homebrewPath, nil
|
||||||
|
}
|
||||||
|
} else if runtime.GOOS == "darwin" && runtime.GOARCH == "amd64" {
|
||||||
|
homebrewPath := "/usr/local/bin/" + ffprobeName
|
||||||
|
if _, err := os.Stat(homebrewPath); err == nil {
|
||||||
|
return homebrewPath, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if runtime.GOOS != "windows" {
|
||||||
|
path, err := exec.Command("which", ffprobeName).Output()
|
||||||
|
if err == nil {
|
||||||
|
trimmed := strings.TrimSpace(string(path))
|
||||||
|
if trimmed != "" {
|
||||||
|
return trimmed, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
path, err := exec.LookPath(ffprobeName)
|
path, err := exec.LookPath(ffprobeName)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return path, nil
|
return path, nil
|
||||||
@@ -161,6 +191,58 @@ func IsFFmpegInstalled() (bool, error) {
|
|||||||
return err == nil, nil
|
return err == nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func GetBrewPath() string {
|
||||||
|
brewPaths := []string{
|
||||||
|
"/opt/homebrew/bin/brew",
|
||||||
|
"/usr/local/bin/brew",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, path := range brewPaths {
|
||||||
|
if _, err := os.Stat(path); err == nil {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsBrewFFmpegInstalled() (bool, error) {
|
||||||
|
brewPath := GetBrewPath()
|
||||||
|
if brewPath == "" {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command(brewPath, "list", "ffmpeg")
|
||||||
|
setHideWindow(cmd)
|
||||||
|
err := cmd.Run()
|
||||||
|
return err == nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func InstallFFmpegWithBrew(progressCallback func(int, string)) error {
|
||||||
|
brewPath := GetBrewPath()
|
||||||
|
if brewPath == "" {
|
||||||
|
return fmt.Errorf("brew not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
progressCallback(10, "Installing FFmpeg via Homebrew...")
|
||||||
|
|
||||||
|
cmd := exec.Command(brewPath, "install", "ffmpeg")
|
||||||
|
setHideWindow(cmd)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to install ffmpeg: %w - %s", err, string(output))
|
||||||
|
}
|
||||||
|
|
||||||
|
progressCallback(100, "done")
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
ffmpegWindowsURL = "https://github.com/afkarxyz/ffmpeg-binaries/releases/download/v8.0/ffmpeg-windows-amd64.zip"
|
||||||
|
ffmpegLinuxURL = "https://github.com/afkarxyz/ffmpeg-binaries/releases/download/v8.0/ffmpeg-linux-amd64.tar.xz"
|
||||||
|
)
|
||||||
|
|
||||||
func DownloadFFmpeg(progressCallback func(int)) error {
|
func DownloadFFmpeg(progressCallback func(int)) error {
|
||||||
|
|
||||||
SetDownloadProgress(0)
|
SetDownloadProgress(0)
|
||||||
@@ -181,54 +263,51 @@ func DownloadFFmpeg(progressCallback func(int)) error {
|
|||||||
ffmpegInstalled, _ := IsFFmpegInstalled()
|
ffmpegInstalled, _ := IsFFmpegInstalled()
|
||||||
ffprobeInstalled, _ := IsFFprobeInstalled()
|
ffprobeInstalled, _ := IsFFprobeInstalled()
|
||||||
|
|
||||||
if !ffmpegInstalled && !ffprobeInstalled {
|
isARM := runtime.GOARCH == "arm64"
|
||||||
|
|
||||||
ffmpegURL, _ := decodeBase64(ffmpegMacOSURL)
|
var macFFmpegURLs []string
|
||||||
fmt.Printf("[FFmpeg] Downloading ffmpeg from: %s\n", ffmpegURL)
|
var macFFprobeURLs []string
|
||||||
if err := downloadAndExtract(ffmpegURL, ffmpegDir, progressCallback, 0, 50); err != nil {
|
|
||||||
|
if isARM {
|
||||||
|
|
||||||
|
macFFmpegURLs = []string{"https://github.com/afkarxyz/ffmpeg-binaries/releases/download/v8.0/ffmpeg-macos-arm64.zip"}
|
||||||
|
macFFprobeURLs = []string{"https://github.com/afkarxyz/ffmpeg-binaries/releases/download/v8.0/ffprobe-macos-arm64.zip"}
|
||||||
|
} else {
|
||||||
|
|
||||||
|
macFFmpegURLs = []string{"https://github.com/afkarxyz/ffmpeg-binaries/releases/download/v8.0/ffmpeg-macos-intel.zip"}
|
||||||
|
macFFprobeURLs = []string{"https://github.com/afkarxyz/ffmpeg-binaries/releases/download/v8.0/ffprobe-macos-intel.zip"}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !ffmpegInstalled && !ffprobeInstalled {
|
||||||
|
if err := downloadWithFallback(macFFmpegURLs, ffmpegDir, progressCallback, 0, 50); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := downloadWithFallback(macFFprobeURLs, ffmpegDir, progressCallback, 50, 100); err != nil {
|
||||||
ffprobeURL, _ := decodeBase64(ffprobeMacOSURL)
|
return err
|
||||||
fmt.Printf("[FFmpeg] Downloading ffprobe from: %s\n", ffprobeURL)
|
|
||||||
if err := downloadAndExtract(ffprobeURL, ffmpegDir, progressCallback, 50, 100); err != nil {
|
|
||||||
return fmt.Errorf("failed to download ffprobe: %w", err)
|
|
||||||
}
|
}
|
||||||
} else if !ffmpegInstalled {
|
} else if !ffmpegInstalled {
|
||||||
|
if err := downloadWithFallback(macFFmpegURLs, ffmpegDir, progressCallback, 0, 100); err != nil {
|
||||||
ffmpegURL, _ := decodeBase64(ffmpegMacOSURL)
|
|
||||||
fmt.Printf("[FFmpeg] Downloading ffmpeg from: %s\n", ffmpegURL)
|
|
||||||
if err := downloadAndExtract(ffmpegURL, ffmpegDir, progressCallback, 0, 100); err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
} else if !ffprobeInstalled {
|
} else if !ffprobeInstalled {
|
||||||
|
if err := downloadWithFallback(macFFprobeURLs, ffmpegDir, progressCallback, 0, 100); err != nil {
|
||||||
ffprobeURL, _ := decodeBase64(ffprobeMacOSURL)
|
return err
|
||||||
fmt.Printf("[FFmpeg] Downloading ffprobe from: %s\n", ffprobeURL)
|
|
||||||
if err := downloadAndExtract(ffprobeURL, ffmpegDir, progressCallback, 0, 100); err != nil {
|
|
||||||
return fmt.Errorf("failed to download ffprobe: %w", err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var encodedURL string
|
var url string
|
||||||
switch runtime.GOOS {
|
switch runtime.GOOS {
|
||||||
case "windows":
|
case "windows":
|
||||||
encodedURL = ffmpegWindowsURL
|
url = ffmpegWindowsURL
|
||||||
case "linux":
|
case "linux":
|
||||||
encodedURL = ffmpegLinuxURL
|
url = ffmpegLinuxURL
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("unsupported operating system: %s", runtime.GOOS)
|
return fmt.Errorf("unsupported operating system: %s", runtime.GOOS)
|
||||||
}
|
}
|
||||||
|
|
||||||
url, err := decodeBase64(encodedURL)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to decode ffmpeg URL: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("[FFmpeg] Downloading from: %s\n", url)
|
fmt.Printf("[FFmpeg] Downloading from: %s\n", url)
|
||||||
|
|
||||||
if err := downloadAndExtract(url, ffmpegDir, progressCallback, 0, 100); err != nil {
|
if err := downloadAndExtract(url, ffmpegDir, progressCallback, 0, 100); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -236,6 +315,20 @@ func DownloadFFmpeg(progressCallback func(int)) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func downloadWithFallback(urls []string, destDir string, progressCallback func(int), start, end int) error {
|
||||||
|
var lastErr error
|
||||||
|
for _, url := range urls {
|
||||||
|
fmt.Printf("[FFmpeg] Trying to download from: %s\n", url)
|
||||||
|
err := downloadAndExtract(url, destDir, progressCallback, start, end)
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
lastErr = err
|
||||||
|
fmt.Printf("[FFmpeg] Attempt failed: %v\n", err)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("all download attempts failed: %w", lastErr)
|
||||||
|
}
|
||||||
|
|
||||||
func downloadAndExtract(url, destDir string, progressCallback func(int), progressStart, progressEnd int) error {
|
func downloadAndExtract(url, destDir string, progressCallback func(int), progressStart, progressEnd int) error {
|
||||||
|
|
||||||
tmpFile, err := os.CreateTemp("", "ffmpeg-*")
|
tmpFile, err := os.CreateTemp("", "ffmpeg-*")
|
||||||
@@ -245,7 +338,14 @@ func downloadAndExtract(url, destDir string, progressCallback func(int), progres
|
|||||||
defer os.Remove(tmpFile.Name())
|
defer os.Remove(tmpFile.Name())
|
||||||
defer tmpFile.Close()
|
defer tmpFile.Close()
|
||||||
|
|
||||||
resp, err := http.Get(url)
|
client := &http.Client{}
|
||||||
|
req, err := http.NewRequest("GET", url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36")
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to download: %w", err)
|
return fmt.Errorf("failed to download: %w", err)
|
||||||
}
|
}
|
||||||
@@ -551,6 +651,7 @@ func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) {
|
|||||||
|
|
||||||
outputExt := "." + strings.ToLower(req.OutputFormat)
|
outputExt := "." + strings.ToLower(req.OutputFormat)
|
||||||
outputFile := filepath.Join(outputDir, baseName+outputExt)
|
outputFile := filepath.Join(outputDir, baseName+outputExt)
|
||||||
|
outputFile = norm.NFC.String(outputFile)
|
||||||
|
|
||||||
if inputExt == outputExt {
|
if inputExt == outputExt {
|
||||||
result.Error = "Input and output formats are the same"
|
result.Error = "Input and output formats are the same"
|
||||||
@@ -572,7 +673,11 @@ func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) {
|
|||||||
fmt.Printf("[FFmpeg] Warning: Failed to extract metadata from %s: %v\n", inputFile, err)
|
fmt.Printf("[FFmpeg] Warning: Failed to extract metadata from %s: %v\n", inputFile, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
coverArtPath, _ = ExtractCoverArt(inputFile)
|
inputFile = norm.NFC.String(inputFile)
|
||||||
|
coverArtPath, err = ExtractCoverArt(inputFile)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("[FFmpeg] Warning: Failed to extract cover art from %s: %v\n", inputFile, err)
|
||||||
|
}
|
||||||
lyrics, err = ExtractLyrics(inputFile)
|
lyrics, err = ExtractLyrics(inputFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("[FFmpeg] Warning: Failed to extract lyrics from %s: %v\n", inputFile, err)
|
fmt.Printf("[FFmpeg] Warning: Failed to extract lyrics from %s: %v\n", inputFile, err)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
//go:build !darwin
|
||||||
|
|
||||||
|
package backend
|
||||||
|
|
||||||
|
func SetMacOSFileIconFromImage(filePath, imagePath string, iconSize int) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -332,6 +332,7 @@ func GenerateFilename(metadata *AudioMetadata, format string, ext string) string
|
|||||||
result = strings.ReplaceAll(result, "{album}", sanitizeFilenameForRename(metadata.Album))
|
result = strings.ReplaceAll(result, "{album}", sanitizeFilenameForRename(metadata.Album))
|
||||||
result = strings.ReplaceAll(result, "{album_artist}", sanitizeFilenameForRename(metadata.AlbumArtist))
|
result = strings.ReplaceAll(result, "{album_artist}", sanitizeFilenameForRename(metadata.AlbumArtist))
|
||||||
result = strings.ReplaceAll(result, "{year}", sanitizeFilenameForRename(year))
|
result = strings.ReplaceAll(result, "{year}", sanitizeFilenameForRename(year))
|
||||||
|
result = strings.ReplaceAll(result, "{date}", sanitizeFilenameForRename(metadata.Year))
|
||||||
|
|
||||||
if metadata.TrackNumber > 0 {
|
if metadata.TrackNumber > 0 {
|
||||||
result = strings.ReplaceAll(result, "{track}", fmt.Sprintf("%02d", metadata.TrackNumber))
|
result = strings.ReplaceAll(result, "{track}", fmt.Sprintf("%02d", metadata.TrackNumber))
|
||||||
|
|||||||
+41
-1
@@ -1,7 +1,9 @@
|
|||||||
package backend
|
package backend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -33,6 +35,7 @@ func BuildExpectedFilename(trackName, artistName, albumName, albumArtist, releas
|
|||||||
filename = strings.ReplaceAll(filename, "{album}", safeAlbum)
|
filename = strings.ReplaceAll(filename, "{album}", safeAlbum)
|
||||||
filename = strings.ReplaceAll(filename, "{album_artist}", safeAlbumArtist)
|
filename = strings.ReplaceAll(filename, "{album_artist}", safeAlbumArtist)
|
||||||
filename = strings.ReplaceAll(filename, "{year}", year)
|
filename = strings.ReplaceAll(filename, "{year}", year)
|
||||||
|
filename = strings.ReplaceAll(filename, "{date}", SanitizeFilename(releaseDate))
|
||||||
filename = strings.ReplaceAll(filename, "{playlist}", safePlaylist)
|
filename = strings.ReplaceAll(filename, "{playlist}", safePlaylist)
|
||||||
filename = strings.ReplaceAll(filename, "{creator}", safeCreator)
|
filename = strings.ReplaceAll(filename, "{creator}", safeCreator)
|
||||||
|
|
||||||
@@ -118,11 +121,48 @@ func SanitizeFilename(name string) string {
|
|||||||
return sanitized
|
return sanitized
|
||||||
}
|
}
|
||||||
|
|
||||||
func NormalizePath(folderPath string) string {
|
func GetFirstArtist(artistString string) string {
|
||||||
|
if artistString == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
delimiters := []string{", ", " & ", " feat. ", " ft. ", " featuring "}
|
||||||
|
for _, d := range delimiters {
|
||||||
|
if idx := strings.Index(strings.ToLower(artistString), d); idx != -1 {
|
||||||
|
return strings.TrimSpace(artistString[:idx])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return artistString
|
||||||
|
}
|
||||||
|
|
||||||
|
func NormalizePath(folderPath string) string {
|
||||||
return strings.ReplaceAll(folderPath, "/", string(filepath.Separator))
|
return strings.ReplaceAll(folderPath, "/", string(filepath.Separator))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func GetSeparator() string {
|
||||||
|
dir, err := GetFFmpegDir()
|
||||||
|
if err != nil {
|
||||||
|
return "; "
|
||||||
|
}
|
||||||
|
configPath := filepath.Join(dir, "config.json")
|
||||||
|
data, err := os.ReadFile(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return "; "
|
||||||
|
}
|
||||||
|
|
||||||
|
var settings map[string]interface{}
|
||||||
|
if err := json.Unmarshal(data, &settings); err == nil {
|
||||||
|
if sep, ok := settings["separator"].(string); ok {
|
||||||
|
if sep == "comma" {
|
||||||
|
return ", "
|
||||||
|
}
|
||||||
|
if sep == "semicolon" {
|
||||||
|
return "; "
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "; "
|
||||||
|
}
|
||||||
|
|
||||||
func SanitizeFolderPath(folderPath string) string {
|
func SanitizeFolderPath(folderPath string) string {
|
||||||
|
|
||||||
normalizedPath := strings.ReplaceAll(folderPath, "/", string(filepath.Separator))
|
normalizedPath := strings.ReplaceAll(folderPath, "/", string(filepath.Separator))
|
||||||
|
|||||||
+17
-1
@@ -50,12 +50,28 @@ func SelectFolderDialog(ctx context.Context, defaultPath string) (string, error)
|
|||||||
|
|
||||||
func SelectFileDialog(ctx context.Context) (string, error) {
|
func SelectFileDialog(ctx context.Context) (string, error) {
|
||||||
options := wailsRuntime.OpenDialogOptions{
|
options := wailsRuntime.OpenDialogOptions{
|
||||||
Title: "Select FLAC File for Analysis",
|
Title: "Select Audio File for Analysis",
|
||||||
Filters: []wailsRuntime.FileFilter{
|
Filters: []wailsRuntime.FileFilter{
|
||||||
|
{
|
||||||
|
DisplayName: "Audio Files (*.flac;*.mp3;*.m4a;*.aac)",
|
||||||
|
Pattern: "*.flac;*.mp3;*.m4a;*.aac",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
DisplayName: "FLAC Audio Files (*.flac)",
|
DisplayName: "FLAC Audio Files (*.flac)",
|
||||||
Pattern: "*.flac",
|
Pattern: "*.flac",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
DisplayName: "MP3 Audio Files (*.mp3)",
|
||||||
|
Pattern: "*.mp3",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
DisplayName: "M4A Audio Files (*.m4a)",
|
||||||
|
Pattern: "*.m4a",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
DisplayName: "AAC Audio Files (*.aac)",
|
||||||
|
Pattern: "*.aac",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
DisplayName: "All Files (*.*)",
|
DisplayName: "All Files (*.*)",
|
||||||
Pattern: "*.*",
|
Pattern: "*.*",
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ type HistoryItem struct {
|
|||||||
Quality string `json:"quality"`
|
Quality string `json:"quality"`
|
||||||
Format string `json:"format"`
|
Format string `json:"format"`
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
|
Source string `json:"source"`
|
||||||
Timestamp int64 `json:"timestamp"`
|
Timestamp int64 `json:"timestamp"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+102
-50
@@ -1,7 +1,6 @@
|
|||||||
package backend
|
package backend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -71,14 +70,16 @@ func NewLyricsClient() *LyricsClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *LyricsClient) FetchLyricsWithMetadata(trackName, artistName string, duration int) (*LyricsResponse, error) {
|
func (c *LyricsClient) FetchLyricsWithMetadata(trackName, artistName, albumName string, duration int) (*LyricsResponse, error) {
|
||||||
|
|
||||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9scmNsaWIubmV0L2FwaS9nZXQ/YXJ0aXN0X25hbWU9")
|
apiURL := fmt.Sprintf("https://lrclib.net/api/get?artist_name=%s&track_name=%s",
|
||||||
apiURL := fmt.Sprintf("%s%s&track_name=%s",
|
|
||||||
string(apiBase),
|
|
||||||
url.QueryEscape(artistName),
|
url.QueryEscape(artistName),
|
||||||
url.QueryEscape(trackName))
|
url.QueryEscape(trackName))
|
||||||
|
|
||||||
|
if albumName != "" {
|
||||||
|
apiURL = fmt.Sprintf("%s&album_name=%s", apiURL, url.QueryEscape(albumName))
|
||||||
|
}
|
||||||
|
|
||||||
if duration > 0 {
|
if duration > 0 {
|
||||||
apiURL = fmt.Sprintf("%s&duration=%d", apiURL, duration)
|
apiURL = fmt.Sprintf("%s&duration=%d", apiURL, duration)
|
||||||
}
|
}
|
||||||
@@ -103,6 +104,10 @@ func (c *LyricsClient) FetchLyricsWithMetadata(trackName, artistName string, dur
|
|||||||
return nil, fmt.Errorf("failed to parse LRCLIB response: %v", err)
|
return nil, fmt.Errorf("failed to parse LRCLIB response: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if lrcLibResp.SyncedLyrics == "" && lrcLibResp.PlainLyrics == "" {
|
||||||
|
return nil, fmt.Errorf("LRCLIB returned empty lyrics")
|
||||||
|
}
|
||||||
|
|
||||||
return c.convertLRCLibToLyricsResponse(&lrcLibResp), nil
|
return c.convertLRCLibToLyricsResponse(&lrcLibResp), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,9 +171,10 @@ func lrcTimestampToMs(timestamp string) int64 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *LyricsClient) FetchLyricsFromLRCLibSearch(trackName, artistName string) (*LyricsResponse, error) {
|
func (c *LyricsClient) FetchLyricsFromLRCLibSearch(trackName, artistName string) (*LyricsResponse, error) {
|
||||||
query := fmt.Sprintf("%s %s", artistName, trackName)
|
|
||||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9scmNsaWIubmV0L2FwaS9zZWFyY2g/cT0=")
|
apiURL := fmt.Sprintf("https://lrclib.net/api/search?artist_name=%s&track_name=%s",
|
||||||
apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(query))
|
url.QueryEscape(artistName),
|
||||||
|
url.QueryEscape(trackName))
|
||||||
|
|
||||||
resp, err := c.httpClient.Get(apiURL)
|
resp, err := c.httpClient.Get(apiURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -194,21 +200,32 @@ func (c *LyricsClient) FetchLyricsFromLRCLibSearch(trackName, artistName string)
|
|||||||
return nil, fmt.Errorf("no results found")
|
return nil, fmt.Errorf("no results found")
|
||||||
}
|
}
|
||||||
|
|
||||||
var best *LRCLibResponse
|
var bestSynced *LRCLibResponse
|
||||||
|
var bestPlain *LRCLibResponse
|
||||||
for i := range results {
|
for i := range results {
|
||||||
if results[i].SyncedLyrics != "" {
|
if results[i].SyncedLyrics != "" && bestSynced == nil {
|
||||||
best = &results[i]
|
bestSynced = &results[i]
|
||||||
break
|
|
||||||
}
|
}
|
||||||
if best == nil && results[i].PlainLyrics != "" {
|
if results[i].PlainLyrics != "" && bestPlain == nil {
|
||||||
best = &results[i]
|
bestPlain = &results[i]
|
||||||
|
}
|
||||||
|
if bestSynced != nil {
|
||||||
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
best := bestSynced
|
||||||
|
if best == nil {
|
||||||
|
best = bestPlain
|
||||||
|
}
|
||||||
if best == nil {
|
if best == nil {
|
||||||
best = &results[0]
|
best = &results[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if best.SyncedLyrics == "" && best.PlainLyrics == "" {
|
||||||
|
return nil, fmt.Errorf("no lyrics found in search results")
|
||||||
|
}
|
||||||
|
|
||||||
return c.convertLRCLibToLyricsResponse(best), nil
|
return c.convertLRCLibToLyricsResponse(best), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,35 +241,88 @@ func simplifyTrackName(name string) string {
|
|||||||
return name
|
return name
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName string, duration int) (*LyricsResponse, string, error) {
|
func isSynced(resp *LyricsResponse) bool {
|
||||||
|
return resp != nil && !resp.Error && resp.SyncType == "LINE_SYNCED" && len(resp.Lines) > 0
|
||||||
|
}
|
||||||
|
|
||||||
resp, err := c.FetchLyricsWithMetadata(trackName, artistName, duration)
|
func hasLyrics(resp *LyricsResponse) bool {
|
||||||
if err == nil && resp != nil && !resp.Error && len(resp.Lines) > 0 {
|
return resp != nil && !resp.Error && len(resp.Lines) > 0
|
||||||
return resp, "LRCLIB", nil
|
}
|
||||||
}
|
|
||||||
fmt.Printf(" LRCLIB exact: %v\n", err)
|
|
||||||
|
|
||||||
resp, err = c.FetchLyricsFromLRCLibSearch(trackName, artistName)
|
func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName, albumName string, duration int) (*LyricsResponse, string, error) {
|
||||||
if err == nil && resp != nil && !resp.Error && len(resp.Lines) > 0 {
|
|
||||||
return resp, "LRCLIB Search", nil
|
var unsyncedFallback *LyricsResponse
|
||||||
|
var unsyncedSource string
|
||||||
|
|
||||||
|
check := func(resp *LyricsResponse, err error, source string) (*LyricsResponse, string, bool) {
|
||||||
|
if err != nil || resp == nil || resp.Error || len(resp.Lines) == 0 {
|
||||||
|
return nil, "", false
|
||||||
|
}
|
||||||
|
if isSynced(resp) {
|
||||||
|
return resp, source, true
|
||||||
|
}
|
||||||
|
|
||||||
|
if unsyncedFallback == nil {
|
||||||
|
unsyncedFallback = resp
|
||||||
|
unsyncedSource = source
|
||||||
|
}
|
||||||
|
return nil, "", false
|
||||||
}
|
}
|
||||||
fmt.Printf(" LRCLIB search: %v\n", err)
|
|
||||||
|
var resp *LyricsResponse
|
||||||
|
var src string
|
||||||
|
var found bool
|
||||||
|
|
||||||
|
resp, _ = c.FetchLyricsWithMetadata(trackName, artistName, albumName, duration)
|
||||||
|
resp, src, found = check(resp, nil, "LRCLIB")
|
||||||
|
if found {
|
||||||
|
fmt.Printf(" [LRCLIB] Synced found via exact match (with album)\n")
|
||||||
|
return resp, src, nil
|
||||||
|
}
|
||||||
|
fmt.Printf(" LRCLIB exact (with album): no synced\n")
|
||||||
|
|
||||||
|
if albumName != "" {
|
||||||
|
resp, _ = c.FetchLyricsWithMetadata(trackName, artistName, "", duration)
|
||||||
|
resp, src, found = check(resp, nil, "LRCLIB (no album)")
|
||||||
|
if found {
|
||||||
|
fmt.Printf(" [LRCLIB] Synced found via exact match (no album)\n")
|
||||||
|
return resp, src, nil
|
||||||
|
}
|
||||||
|
fmt.Printf(" LRCLIB exact (no album): no synced\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, _ = c.FetchLyricsFromLRCLibSearch(trackName, artistName)
|
||||||
|
resp, src, found = check(resp, nil, "LRCLIB Search")
|
||||||
|
if found {
|
||||||
|
fmt.Printf(" [LRCLIB] Synced found via search\n")
|
||||||
|
return resp, src, nil
|
||||||
|
}
|
||||||
|
fmt.Printf(" LRCLIB search: no synced\n")
|
||||||
|
|
||||||
simplifiedTrack := simplifyTrackName(trackName)
|
simplifiedTrack := simplifyTrackName(trackName)
|
||||||
if simplifiedTrack != trackName {
|
if simplifiedTrack != trackName {
|
||||||
fmt.Printf(" Trying simplified name: %s\n", simplifiedTrack)
|
fmt.Printf(" Trying simplified name: %s\n", simplifiedTrack)
|
||||||
|
|
||||||
resp, err = c.FetchLyricsWithMetadata(simplifiedTrack, artistName, duration)
|
resp, _ = c.FetchLyricsWithMetadata(simplifiedTrack, artistName, albumName, duration)
|
||||||
if err == nil && resp != nil && !resp.Error && len(resp.Lines) > 0 {
|
resp, src, found = check(resp, nil, "LRCLIB (simplified)")
|
||||||
return resp, "LRCLIB (simplified)", nil
|
if found {
|
||||||
|
fmt.Printf(" [LRCLIB] Synced found via simplified exact\n")
|
||||||
|
return resp, src, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err = c.FetchLyricsFromLRCLibSearch(simplifiedTrack, artistName)
|
resp, _ = c.FetchLyricsFromLRCLibSearch(simplifiedTrack, artistName)
|
||||||
if err == nil && resp != nil && !resp.Error && len(resp.Lines) > 0 {
|
resp, src, found = check(resp, nil, "LRCLIB Search (simplified)")
|
||||||
return resp, "LRCLIB Search (simplified)", nil
|
if found {
|
||||||
|
fmt.Printf(" [LRCLIB] Synced found via simplified search\n")
|
||||||
|
return resp, src, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if unsyncedFallback != nil {
|
||||||
|
fmt.Printf(" [LRCLIB] No synced found, using unsynced from: %s\n", unsyncedSource)
|
||||||
|
return unsyncedFallback, unsyncedSource + " (unsynced)", nil
|
||||||
|
}
|
||||||
|
|
||||||
return nil, "", fmt.Errorf("lyrics not found in any source")
|
return nil, "", fmt.Errorf("lyrics not found in any source")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,6 +383,7 @@ func buildLyricsFilename(trackName, artistName, albumName, albumArtist, releaseD
|
|||||||
filename = strings.ReplaceAll(filename, "{album}", safeAlbum)
|
filename = strings.ReplaceAll(filename, "{album}", safeAlbum)
|
||||||
filename = strings.ReplaceAll(filename, "{album_artist}", safeAlbumArtist)
|
filename = strings.ReplaceAll(filename, "{album_artist}", safeAlbumArtist)
|
||||||
filename = strings.ReplaceAll(filename, "{year}", year)
|
filename = strings.ReplaceAll(filename, "{year}", year)
|
||||||
|
filename = strings.ReplaceAll(filename, "{date}", sanitizeFilename(releaseDate))
|
||||||
|
|
||||||
if discNumber > 0 {
|
if discNumber > 0 {
|
||||||
filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber))
|
filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber))
|
||||||
@@ -403,25 +474,6 @@ func (c *LyricsClient) DownloadLyrics(req LyricsDownloadRequest) (*LyricsDownloa
|
|||||||
outputDir = NormalizePath(outputDir)
|
outputDir = NormalizePath(outputDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
safeArtist := sanitizeFilename(req.AlbumArtist)
|
|
||||||
if safeArtist == "" {
|
|
||||||
safeArtist = sanitizeFilename(req.ArtistName)
|
|
||||||
}
|
|
||||||
safeAlbum := sanitizeFilename(req.AlbumName)
|
|
||||||
|
|
||||||
if safeArtist != "" && safeAlbum != "" {
|
|
||||||
artistAlbumPath := filepath.Join(outputDir, safeArtist, safeAlbum)
|
|
||||||
if info, err := os.Stat(artistAlbumPath); err == nil && info.IsDir() {
|
|
||||||
outputDir = artistAlbumPath
|
|
||||||
} else {
|
|
||||||
|
|
||||||
artistPath := filepath.Join(outputDir, safeArtist)
|
|
||||||
if info, err := os.Stat(artistPath); err == nil && info.IsDir() {
|
|
||||||
outputDir = artistPath
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||||
return &LyricsDownloadResponse{
|
return &LyricsDownloadResponse{
|
||||||
Success: false,
|
Success: false,
|
||||||
@@ -455,7 +507,7 @@ func (c *LyricsClient) DownloadLyrics(req LyricsDownloadRequest) (*LyricsDownloa
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
lyrics, _, err := c.FetchLyricsAllSources(req.SpotifyID, req.TrackName, req.ArtistName, audioDuration)
|
lyrics, _, err := c.FetchLyricsAllSources(req.SpotifyID, req.TrackName, req.ArtistName, req.AlbumName, audioDuration)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &LyricsDownloadResponse{
|
return &LyricsDownloadResponse{
|
||||||
Success: false,
|
Success: false,
|
||||||
|
|||||||
+145
-18
@@ -13,6 +13,7 @@ import (
|
|||||||
"github.com/go-flac/flacpicture"
|
"github.com/go-flac/flacpicture"
|
||||||
"github.com/go-flac/flacvorbis"
|
"github.com/go-flac/flacvorbis"
|
||||||
"github.com/go-flac/go-flac"
|
"github.com/go-flac/go-flac"
|
||||||
|
"golang.org/x/text/unicode/norm"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Metadata struct {
|
type Metadata struct {
|
||||||
@@ -31,6 +32,8 @@ type Metadata struct {
|
|||||||
Publisher string
|
Publisher string
|
||||||
Lyrics string
|
Lyrics string
|
||||||
Description string
|
Description string
|
||||||
|
ISRC string
|
||||||
|
Genre string
|
||||||
}
|
}
|
||||||
|
|
||||||
func EmbedMetadata(filepath string, metadata Metadata, coverPath string) error {
|
func EmbedMetadata(filepath string, metadata Metadata, coverPath string) error {
|
||||||
@@ -86,6 +89,14 @@ func EmbedMetadata(filepath string, metadata Metadata, coverPath string) error {
|
|||||||
_ = cmt.Add("DESCRIPTION", metadata.Description)
|
_ = cmt.Add("DESCRIPTION", metadata.Description)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if metadata.ISRC != "" {
|
||||||
|
_ = cmt.Add("ISRC", metadata.ISRC)
|
||||||
|
}
|
||||||
|
|
||||||
|
if metadata.Genre != "" {
|
||||||
|
_ = cmt.Add("GENRE", metadata.Genre)
|
||||||
|
}
|
||||||
|
|
||||||
if metadata.Lyrics != "" {
|
if metadata.Lyrics != "" {
|
||||||
_ = cmt.Add("LYRICS", metadata.Lyrics)
|
_ = cmt.Add("LYRICS", metadata.Lyrics)
|
||||||
}
|
}
|
||||||
@@ -208,16 +219,68 @@ func EmbedLyricsOnly(filepath string, lyrics string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ExtractCoverArt(filePath string) (string, error) {
|
func ExtractCoverArt(filePath string) (string, error) {
|
||||||
|
filePath = norm.NFC.String(filePath)
|
||||||
ext := strings.ToLower(pathfilepath.Ext(filePath))
|
ext := strings.ToLower(pathfilepath.Ext(filePath))
|
||||||
|
|
||||||
|
var coverPath string
|
||||||
|
var err error
|
||||||
|
|
||||||
switch ext {
|
switch ext {
|
||||||
case ".mp3":
|
case ".mp3":
|
||||||
return extractCoverFromMp3(filePath)
|
coverPath, err = extractCoverFromMp3(filePath)
|
||||||
case ".m4a", ".flac":
|
case ".m4a", ".flac":
|
||||||
return extractCoverFromM4AOrFlac(filePath)
|
coverPath, err = extractCoverFromM4AOrFlac(filePath)
|
||||||
default:
|
default:
|
||||||
return "", fmt.Errorf("unsupported file format: %s", ext)
|
return "", fmt.Errorf("unsupported file format: %s", ext)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err != nil || coverPath == "" {
|
||||||
|
fmt.Printf("[ExtractCoverArt] Library extraction failed for %s, trying FFmpeg fallback...\n", filePath)
|
||||||
|
ffmpegCover, ffmpegErr := extractCoverWithFFmpeg(filePath)
|
||||||
|
if ffmpegErr == nil {
|
||||||
|
return ffmpegCover, nil
|
||||||
|
}
|
||||||
|
return coverPath, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return coverPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractCoverWithFFmpeg(filePath string) (string, error) {
|
||||||
|
ffmpegPath, err := GetFFmpegPath()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpFile, err := os.CreateTemp("", "cover-*.jpg")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
tmpPath := tmpFile.Name()
|
||||||
|
tmpFile.Close()
|
||||||
|
|
||||||
|
cmd := exec.Command(ffmpegPath,
|
||||||
|
"-i", filePath,
|
||||||
|
"-an",
|
||||||
|
"-vframes", "1",
|
||||||
|
"-f", "image2",
|
||||||
|
"-update", "1",
|
||||||
|
"-y",
|
||||||
|
tmpPath,
|
||||||
|
)
|
||||||
|
|
||||||
|
setHideWindow(cmd)
|
||||||
|
if output, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
os.Remove(tmpPath)
|
||||||
|
return "", fmt.Errorf("ffmpeg cover extraction failed: %v, output: %s", err, string(output))
|
||||||
|
}
|
||||||
|
|
||||||
|
if info, err := os.Stat(tmpPath); err != nil || info.Size() == 0 {
|
||||||
|
os.Remove(tmpPath)
|
||||||
|
return "", fmt.Errorf("ffmpeg produced empty cover file")
|
||||||
|
}
|
||||||
|
|
||||||
|
return tmpPath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func extractCoverFromMp3(filePath string) (string, error) {
|
func extractCoverFromMp3(filePath string) (string, error) {
|
||||||
@@ -288,19 +351,71 @@ func extractCoverFromM4AOrFlac(filePath string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ExtractLyrics(filePath string) (string, error) {
|
func ExtractLyrics(filePath string) (string, error) {
|
||||||
|
filePath = norm.NFC.String(filePath)
|
||||||
ext := strings.ToLower(pathfilepath.Ext(filePath))
|
ext := strings.ToLower(pathfilepath.Ext(filePath))
|
||||||
|
|
||||||
|
var lyrics string
|
||||||
|
var err error
|
||||||
|
|
||||||
switch ext {
|
switch ext {
|
||||||
case ".mp3":
|
case ".mp3":
|
||||||
return extractLyricsFromMp3(filePath)
|
lyrics, err = extractLyricsFromMp3(filePath)
|
||||||
case ".flac":
|
case ".flac":
|
||||||
return extractLyricsFromFlac(filePath)
|
lyrics, err = extractLyricsFromFlac(filePath)
|
||||||
case ".m4a":
|
case ".m4a":
|
||||||
|
|
||||||
return "", nil
|
return "", nil
|
||||||
default:
|
default:
|
||||||
return "", fmt.Errorf("unsupported file format: %s", ext)
|
return "", fmt.Errorf("unsupported file format: %s", ext)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (err != nil || lyrics == "") && ext != ".m4a" {
|
||||||
|
fmt.Printf("[ExtractLyrics] Library extraction failed for %s, trying ffprobe fallback...\n", filePath)
|
||||||
|
ffprobeLyrics, ffprobeErr := extractLyricsWithFFprobe(filePath)
|
||||||
|
if ffprobeErr == nil && ffprobeLyrics != "" {
|
||||||
|
return ffprobeLyrics, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lyrics, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractLyricsWithFFprobe(filePath string) (string, error) {
|
||||||
|
ffprobePath, err := GetFFprobePath()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command(ffprobePath,
|
||||||
|
"-v", "quiet",
|
||||||
|
"-show_entries", "format_tags=lyrics:format_tags=unsyncedlyrics:format_tags=lyric",
|
||||||
|
"-of", "json",
|
||||||
|
filePath,
|
||||||
|
)
|
||||||
|
|
||||||
|
setHideWindow(cmd)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Format struct {
|
||||||
|
Tags map[string]string `json:"tags"`
|
||||||
|
} `json:"format"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(output, &result); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
tags := result.Format.Tags
|
||||||
|
for _, key := range []string{"lyrics", "unsyncedlyrics", "lyric", "LYRICS", "UNSYNCEDLYRICS", "LYRIC"} {
|
||||||
|
if val, ok := tags[key]; ok && val != "" {
|
||||||
|
return val, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func extractLyricsFromMp3(filePath string) (string, error) {
|
func extractLyricsFromMp3(filePath string) (string, error) {
|
||||||
@@ -504,6 +619,13 @@ func EmbedLyricsOnlyUniversal(filepath string, lyrics string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
validatedLyrics, err := validateLyricsDuration(lyrics, filepath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("[EmbedLyricsOnlyUniversal] Warning: Failed to validate lyrics duration: %v, using original lyrics\n", err)
|
||||||
|
validatedLyrics = lyrics
|
||||||
|
}
|
||||||
|
lyrics = validatedLyrics
|
||||||
|
|
||||||
ext := strings.ToLower(pathfilepath.Ext(filepath))
|
ext := strings.ToLower(pathfilepath.Ext(filepath))
|
||||||
switch ext {
|
switch ext {
|
||||||
case ".mp3":
|
case ".mp3":
|
||||||
@@ -635,27 +757,22 @@ func validateLyricsDuration(lyrics string, filepath string) (string, error) {
|
|||||||
|
|
||||||
if strings.HasPrefix(trimmedLine, "[") {
|
if strings.HasPrefix(trimmedLine, "[") {
|
||||||
|
|
||||||
if strings.Index(trimmedLine, ":") > 0 {
|
|
||||||
|
|
||||||
validLines = append(validLines, line)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
closeBracket := strings.Index(trimmedLine, "]")
|
closeBracket := strings.Index(trimmedLine, "]")
|
||||||
if closeBracket > 0 {
|
if closeBracket > 0 {
|
||||||
timestampStr := trimmedLine[1:closeBracket]
|
timestampStr := trimmedLine[1:closeBracket]
|
||||||
|
|
||||||
ms := parseLRCTimestamp(timestampStr)
|
ms := parseLRCTimestamp(timestampStr)
|
||||||
if ms >= 0 && ms <= durationMs {
|
if ms >= 0 {
|
||||||
|
if ms <= durationMs {
|
||||||
validLines = append(validLines, line)
|
validLines = append(validLines, line)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("[ValidateLyrics] Filtered out line with timestamp %s (exceeds duration %d ms): %s\n", timestampStr, durationMs, trimmedLine)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
||||||
fmt.Printf("[ValidateLyrics] Filtered out line with timestamp %s (exceeds duration %d ms): %s\n", timestampStr, durationMs, trimmedLine)
|
validLines = append(validLines, line)
|
||||||
}
|
}
|
||||||
} else {
|
continue
|
||||||
|
|
||||||
validLines = append(validLines, line)
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
||||||
@@ -676,6 +793,7 @@ func parseLRCTimestamp(timestamp string) int64 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ExtractFullMetadataFromFile(filePath string) (Metadata, error) {
|
func ExtractFullMetadataFromFile(filePath string) (Metadata, error) {
|
||||||
|
filePath = norm.NFC.String(filePath)
|
||||||
var metadata Metadata
|
var metadata Metadata
|
||||||
|
|
||||||
ffprobePath, err := GetFFprobePath()
|
ffprobePath, err := GetFFprobePath()
|
||||||
@@ -784,6 +902,7 @@ func ExtractFullMetadataFromFile(filePath string) (Metadata, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func EmbedMetadataToConvertedFile(filePath string, metadata Metadata, coverPath string) error {
|
func EmbedMetadataToConvertedFile(filePath string, metadata Metadata, coverPath string) error {
|
||||||
|
filePath = norm.NFC.String(filePath)
|
||||||
ext := strings.ToLower(pathfilepath.Ext(filePath))
|
ext := strings.ToLower(pathfilepath.Ext(filePath))
|
||||||
|
|
||||||
switch ext {
|
switch ext {
|
||||||
@@ -858,6 +977,11 @@ func embedMetadataToMP3(filePath string, metadata Metadata, coverPath string) er
|
|||||||
tag.AddTextFrame("TPUB", id3v2.EncodingUTF8, metadata.Publisher)
|
tag.AddTextFrame("TPUB", id3v2.EncodingUTF8, metadata.Publisher)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if metadata.ISRC != "" {
|
||||||
|
tag.DeleteFrames("TSRC")
|
||||||
|
tag.AddTextFrame("TSRC", id3v2.EncodingUTF8, metadata.ISRC)
|
||||||
|
}
|
||||||
|
|
||||||
if coverPath != "" && fileExists(coverPath) {
|
if coverPath != "" && fileExists(coverPath) {
|
||||||
|
|
||||||
tag.DeleteFrames(tag.CommonID("Attached picture"))
|
tag.DeleteFrames(tag.CommonID("Attached picture"))
|
||||||
@@ -941,6 +1065,9 @@ func embedMetadataToM4A(filePath string, metadata Metadata, coverPath string) er
|
|||||||
if metadata.Publisher != "" {
|
if metadata.Publisher != "" {
|
||||||
args = append(args, "-metadata", "publisher="+metadata.Publisher)
|
args = append(args, "-metadata", "publisher="+metadata.Publisher)
|
||||||
}
|
}
|
||||||
|
if metadata.ISRC != "" {
|
||||||
|
args = append(args, "-metadata", "isrc="+metadata.ISRC)
|
||||||
|
}
|
||||||
|
|
||||||
tmpOutputFile := strings.TrimSuffix(filePath, pathfilepath.Ext(filePath)) + ".tmp" + pathfilepath.Ext(filePath)
|
tmpOutputFile := strings.TrimSuffix(filePath, pathfilepath.Ext(filePath)) + ".tmp" + pathfilepath.Ext(filePath)
|
||||||
defer func() {
|
defer func() {
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
package backend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/text/cases"
|
||||||
|
"golang.org/x/text/language"
|
||||||
|
)
|
||||||
|
|
||||||
|
var AppVersion = "Unknown"
|
||||||
|
|
||||||
|
const musicBrainzAPIBase = "https://musicbrainz.org/ws/2"
|
||||||
|
|
||||||
|
type MusicBrainzRecordingResponse struct {
|
||||||
|
Recordings []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Length int `json:"length"`
|
||||||
|
Releases []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
ReleaseGroup struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
PrimaryType string `json:"primary-type"`
|
||||||
|
} `json:"release-group"`
|
||||||
|
Date string `json:"date"`
|
||||||
|
Country string `json:"country"`
|
||||||
|
Media []struct {
|
||||||
|
Format string `json:"format"`
|
||||||
|
} `json:"media"`
|
||||||
|
LabelInfo []struct {
|
||||||
|
Label struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"label"`
|
||||||
|
} `json:"label-info"`
|
||||||
|
} `json:"releases"`
|
||||||
|
ArtistCredit []struct {
|
||||||
|
Artist struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"artist"`
|
||||||
|
} `json:"artist-credit"`
|
||||||
|
Tags []struct {
|
||||||
|
Count int `json:"count"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"tags"`
|
||||||
|
} `json:"recordings"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func FetchMusicBrainzMetadata(isrc, title, artist, album string, useSingleGenre bool, embedGenre bool) (Metadata, error) {
|
||||||
|
var meta Metadata
|
||||||
|
|
||||||
|
if !embedGenre {
|
||||||
|
return meta, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if isrc == "" {
|
||||||
|
return meta, fmt.Errorf("no ISRC provided")
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &http.Client{
|
||||||
|
Timeout: 10 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
query := fmt.Sprintf("isrc:%s", isrc)
|
||||||
|
reqURL := fmt.Sprintf("%s/recording?query=%s&fmt=json&inc=releases+artist-credits+tags+media+release-groups+labels", musicBrainzAPIBase, url.QueryEscape(query))
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", reqURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return meta, err
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("User-Agent", fmt.Sprintf("SpotiFLAC/%s ( hi@afkarxyz.qzz.io )", AppVersion))
|
||||||
|
|
||||||
|
var resp *http.Response
|
||||||
|
var lastErr error
|
||||||
|
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
resp, lastErr = client.Do(req)
|
||||||
|
if lastErr == nil && resp.StatusCode == http.StatusOK {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp != nil {
|
||||||
|
resp.Body.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
if i < 2 {
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if lastErr != nil {
|
||||||
|
return meta, lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
resp.Body.Close()
|
||||||
|
return meta, fmt.Errorf("MusicBrainz API returned status: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var mbResp MusicBrainzRecordingResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&mbResp); err != nil {
|
||||||
|
return meta, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(mbResp.Recordings) == 0 {
|
||||||
|
return meta, fmt.Errorf("no recordings found for ISRC: %s", isrc)
|
||||||
|
}
|
||||||
|
|
||||||
|
recording := mbResp.Recordings[0]
|
||||||
|
|
||||||
|
var genres []string
|
||||||
|
caser := cases.Title(language.English)
|
||||||
|
|
||||||
|
if useSingleGenre {
|
||||||
|
|
||||||
|
maxCount := -1
|
||||||
|
var bestTag string
|
||||||
|
|
||||||
|
for _, tag := range recording.Tags {
|
||||||
|
if tag.Count > maxCount {
|
||||||
|
maxCount = tag.Count
|
||||||
|
bestTag = tag.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if bestTag != "" {
|
||||||
|
meta.Genre = caser.String(bestTag)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for _, tag := range recording.Tags {
|
||||||
|
|
||||||
|
genres = append(genres, caser.String(tag.Name))
|
||||||
|
}
|
||||||
|
if len(genres) > 0 {
|
||||||
|
|
||||||
|
if len(genres) > 5 {
|
||||||
|
genres = genres[:5]
|
||||||
|
}
|
||||||
|
meta.Genre = strings.Join(genres, GetSeparator())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return meta, nil
|
||||||
|
}
|
||||||
+3
-3
@@ -22,7 +22,7 @@ type DownloadItem struct {
|
|||||||
TrackName string `json:"track_name"`
|
TrackName string `json:"track_name"`
|
||||||
ArtistName string `json:"artist_name"`
|
ArtistName string `json:"artist_name"`
|
||||||
AlbumName string `json:"album_name"`
|
AlbumName string `json:"album_name"`
|
||||||
ISRC string `json:"isrc"`
|
SpotifyID string `json:"spotify_id"`
|
||||||
Status DownloadStatus `json:"status"`
|
Status DownloadStatus `json:"status"`
|
||||||
Progress float64 `json:"progress"`
|
Progress float64 `json:"progress"`
|
||||||
TotalSize float64 `json:"total_size"`
|
TotalSize float64 `json:"total_size"`
|
||||||
@@ -184,7 +184,7 @@ func (pw *ProgressWriter) GetTotal() int64 {
|
|||||||
return pw.total
|
return pw.total
|
||||||
}
|
}
|
||||||
|
|
||||||
func AddToQueue(id, trackName, artistName, albumName, isrc string) {
|
func AddToQueue(id, trackName, artistName, albumName, spotifyID string) {
|
||||||
downloadQueueLock.Lock()
|
downloadQueueLock.Lock()
|
||||||
defer downloadQueueLock.Unlock()
|
defer downloadQueueLock.Unlock()
|
||||||
|
|
||||||
@@ -193,7 +193,7 @@ func AddToQueue(id, trackName, artistName, albumName, isrc string) {
|
|||||||
TrackName: trackName,
|
TrackName: trackName,
|
||||||
ArtistName: artistName,
|
ArtistName: artistName,
|
||||||
AlbumName: albumName,
|
AlbumName: albumName,
|
||||||
ISRC: isrc,
|
SpotifyID: spotifyID,
|
||||||
Status: StatusQueued,
|
Status: StatusQueued,
|
||||||
Progress: 0,
|
Progress: 0,
|
||||||
TotalSize: 0,
|
TotalSize: 0,
|
||||||
|
|||||||
+56
-83
@@ -77,7 +77,7 @@ func NewQobuzDownloader() *QobuzDownloader {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *QobuzDownloader) SearchByISRC(isrc string) (*QobuzTrack, error) {
|
func (q *QobuzDownloader) searchByISRC(isrc string) (*QobuzTrack, error) {
|
||||||
apiBase := "https://www.qobuz.com/api.json/0.2/track/search?query="
|
apiBase := "https://www.qobuz.com/api.json/0.2/track/search?query="
|
||||||
url := fmt.Sprintf("%s%s&limit=1&app_id=%s", apiBase, isrc, q.appID)
|
url := fmt.Sprintf("%s%s&limit=1&app_id=%s", apiBase, isrc, q.appID)
|
||||||
|
|
||||||
@@ -118,81 +118,15 @@ func (q *QobuzDownloader) SearchByISRC(isrc string) (*QobuzTrack, error) {
|
|||||||
return &searchResp.Tracks.Items[0], nil
|
return &searchResp.Tracks.Items[0], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func decodeXOR(data []byte) string {
|
func buildQobuzAPIURL(apiBase string, trackID int64, quality string) string {
|
||||||
text := string(data)
|
if strings.Contains(apiBase, "qbz.afkarxyz.qzz.io") {
|
||||||
runes := []rune(text)
|
return fmt.Sprintf("%s%d?quality=%s", apiBase, trackID, quality)
|
||||||
result := make([]rune, len(runes))
|
|
||||||
for i, char := range runes {
|
|
||||||
key := rune((i * 17) % 128)
|
|
||||||
result[i] = char ^ 253 ^ key
|
|
||||||
}
|
}
|
||||||
return string(result)
|
return fmt.Sprintf("%s%d&quality=%s", apiBase, trackID, quality)
|
||||||
}
|
|
||||||
|
|
||||||
func (q *QobuzDownloader) mapJumoQuality(quality string) int {
|
|
||||||
switch quality {
|
|
||||||
case "6":
|
|
||||||
return 6
|
|
||||||
case "7":
|
|
||||||
return 7
|
|
||||||
case "27":
|
|
||||||
return 27
|
|
||||||
default:
|
|
||||||
return 6
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (q *QobuzDownloader) DownloadFromJumo(trackID int64, quality string) (string, error) {
|
|
||||||
formatID := q.mapJumoQuality(quality)
|
|
||||||
region := "US"
|
|
||||||
url := fmt.Sprintf("https://jumo-dl.pages.dev/get?track_id=%d&format_id=%d®ion=%s", trackID, formatID, region)
|
|
||||||
|
|
||||||
client := &http.Client{Timeout: 30 * time.Second}
|
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", url, nil)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36")
|
|
||||||
req.Header.Set("Referer", "https://jumo-dl.pages.dev/")
|
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
return "", fmt.Errorf("HTTP %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
var result struct {
|
|
||||||
URL string `json:"url"`
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := json.Unmarshal(body, &result); err != nil {
|
|
||||||
|
|
||||||
decoded := decodeXOR(body)
|
|
||||||
if err := json.Unmarshal([]byte(decoded), &result); err != nil {
|
|
||||||
return "", fmt.Errorf("failed to parse JSON (plain or XOR): %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if result.URL != "" {
|
|
||||||
return result.URL, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return "", fmt.Errorf("URL not found in Jumo response")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *QobuzDownloader) DownloadFromStandard(apiBase string, trackID int64, quality string) (string, error) {
|
func (q *QobuzDownloader) DownloadFromStandard(apiBase string, trackID int64, quality string) (string, error) {
|
||||||
apiURL := fmt.Sprintf("%s%d&quality=%s", apiBase, trackID, quality)
|
apiURL := buildQobuzAPIURL(apiBase, trackID, quality)
|
||||||
resp, err := q.client.Get(apiURL)
|
resp, err := q.client.Get(apiURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -240,7 +174,7 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string, allowFal
|
|||||||
standardAPIs := []string{
|
standardAPIs := []string{
|
||||||
"https://dab.yeet.su/api/stream?trackId=",
|
"https://dab.yeet.su/api/stream?trackId=",
|
||||||
"https://dabmusic.xyz/api/stream?trackId=",
|
"https://dabmusic.xyz/api/stream?trackId=",
|
||||||
"https://qobuz.squid.wtf/api/download-music?track_id=",
|
"https://qbz.afkarxyz.qzz.io/api/track/",
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadFunc := func(qual string) (string, error) {
|
downloadFunc := func(qual string) (string, error) {
|
||||||
@@ -261,13 +195,6 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string, allowFal
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
providers = append(providers, Provider{
|
|
||||||
Name: "Jumo-DL",
|
|
||||||
Func: func() (string, error) {
|
|
||||||
return q.DownloadFromJumo(trackID, qual)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
rand.Seed(time.Now().UnixNano())
|
rand.Seed(time.Now().UnixNano())
|
||||||
rand.Shuffle(len(providers), func(i, j int) { providers[i], providers[j] = providers[j], providers[i] })
|
rand.Shuffle(len(providers), func(i, j int) { providers[i], providers[j] = providers[j], providers[i] })
|
||||||
|
|
||||||
@@ -399,6 +326,7 @@ func buildQobuzFilename(title, artist, album, albumArtist, releaseDate string, t
|
|||||||
filename = strings.ReplaceAll(filename, "{album}", album)
|
filename = strings.ReplaceAll(filename, "{album}", album)
|
||||||
filename = strings.ReplaceAll(filename, "{album_artist}", albumArtist)
|
filename = strings.ReplaceAll(filename, "{album_artist}", albumArtist)
|
||||||
filename = strings.ReplaceAll(filename, "{year}", year)
|
filename = strings.ReplaceAll(filename, "{year}", year)
|
||||||
|
filename = strings.ReplaceAll(filename, "{date}", SanitizeFilename(releaseDate))
|
||||||
|
|
||||||
if discNumber > 0 {
|
if discNumber > 0 {
|
||||||
filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber))
|
filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber))
|
||||||
@@ -433,16 +361,48 @@ func buildQobuzFilename(title, artist, album, albumArtist, releaseDate string, t
|
|||||||
return filename + ".flac"
|
return filename + ".flac"
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *QobuzDownloader) DownloadByISRC(deezerISRC, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, allowFallback bool) (string, error) {
|
func (q *QobuzDownloader) DownloadTrack(spotifyID, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) {
|
||||||
|
var deezerISRC string
|
||||||
|
if spotifyID != "" {
|
||||||
|
songlinkClient := NewSongLinkClient()
|
||||||
|
isrc, err := songlinkClient.GetISRCDirect(spotifyID)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to get ISRC: %v", err)
|
||||||
|
}
|
||||||
|
deezerISRC = isrc
|
||||||
|
} else {
|
||||||
|
return "", fmt.Errorf("spotify ID is required for Qobuz download")
|
||||||
|
}
|
||||||
|
|
||||||
|
return q.DownloadTrackWithISRC(deezerISRC, spotifyID, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyURL, allowFallback, useFirstArtistOnly, useSingleGenre, embedGenre)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *QobuzDownloader) DownloadTrackWithISRC(deezerISRC, spotifyID, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) {
|
||||||
fmt.Printf("Fetching track info for ISRC: %s\n", deezerISRC)
|
fmt.Printf("Fetching track info for ISRC: %s\n", deezerISRC)
|
||||||
|
|
||||||
|
metaChan := make(chan Metadata, 1)
|
||||||
|
if embedGenre && deezerISRC != "" {
|
||||||
|
go func() {
|
||||||
|
fmt.Println("Fetching MusicBrainz metadata...")
|
||||||
|
if fetchedMeta, err := FetchMusicBrainzMetadata(deezerISRC, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useSingleGenre, embedGenre); err == nil {
|
||||||
|
fmt.Println("✓ MusicBrainz metadata fetched")
|
||||||
|
metaChan <- fetchedMeta
|
||||||
|
} else {
|
||||||
|
fmt.Printf("Warning: Failed to fetch MusicBrainz metadata: %v\n", err)
|
||||||
|
metaChan <- Metadata{}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
} else {
|
||||||
|
close(metaChan)
|
||||||
|
}
|
||||||
|
|
||||||
if outputDir != "." {
|
if outputDir != "." {
|
||||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||||
return "", fmt.Errorf("failed to create output directory: %w", err)
|
return "", fmt.Errorf("failed to create output directory: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
track, err := q.SearchByISRC(deezerISRC)
|
track, err := q.searchByISRC(deezerISRC)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -477,9 +437,15 @@ func (q *QobuzDownloader) DownloadByISRC(deezerISRC, outputDir, quality, filenam
|
|||||||
fmt.Printf("Download URL obtained: %s\n", urlPreview)
|
fmt.Printf("Download URL obtained: %s\n", urlPreview)
|
||||||
|
|
||||||
safeArtist := sanitizeFilename(artists)
|
safeArtist := sanitizeFilename(artists)
|
||||||
|
safeAlbumArtist := sanitizeFilename(spotifyAlbumArtist)
|
||||||
|
|
||||||
|
if useFirstArtistOnly {
|
||||||
|
safeArtist = sanitizeFilename(GetFirstArtist(artists))
|
||||||
|
safeAlbumArtist = sanitizeFilename(GetFirstArtist(spotifyAlbumArtist))
|
||||||
|
}
|
||||||
|
|
||||||
safeTitle := sanitizeFilename(trackTitle)
|
safeTitle := sanitizeFilename(trackTitle)
|
||||||
safeAlbum := sanitizeFilename(albumTitle)
|
safeAlbum := sanitizeFilename(albumTitle)
|
||||||
safeAlbumArtist := sanitizeFilename(spotifyAlbumArtist)
|
|
||||||
|
|
||||||
filename := buildQobuzFilename(safeTitle, safeArtist, safeAlbum, safeAlbumArtist, spotifyReleaseDate, spotifyTrackNumber, spotifyDiscNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber)
|
filename := buildQobuzFilename(safeTitle, safeArtist, safeAlbum, safeAlbumArtist, spotifyReleaseDate, spotifyTrackNumber, spotifyDiscNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber)
|
||||||
filepath := filepath.Join(outputDir, filename)
|
filepath := filepath.Join(outputDir, filename)
|
||||||
@@ -510,6 +476,11 @@ func (q *QobuzDownloader) DownloadByISRC(deezerISRC, outputDir, quality, filenam
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var mbMeta Metadata
|
||||||
|
if deezerISRC != "" {
|
||||||
|
mbMeta = <-metaChan
|
||||||
|
}
|
||||||
|
|
||||||
fmt.Println("Embedding metadata and cover art...")
|
fmt.Println("Embedding metadata and cover art...")
|
||||||
|
|
||||||
trackNumberToEmbed := spotifyTrackNumber
|
trackNumberToEmbed := spotifyTrackNumber
|
||||||
@@ -531,6 +502,8 @@ func (q *QobuzDownloader) DownloadByISRC(deezerISRC, outputDir, quality, filenam
|
|||||||
Copyright: spotifyCopyright,
|
Copyright: spotifyCopyright,
|
||||||
Publisher: spotifyPublisher,
|
Publisher: spotifyPublisher,
|
||||||
Description: "https://github.com/afkarxyz/SpotiFLAC",
|
Description: "https://github.com/afkarxyz/SpotiFLAC",
|
||||||
|
ISRC: deezerISRC,
|
||||||
|
Genre: mbMeta.Genre,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := EmbedMetadata(filepath, metadata, coverPath); err != nil {
|
if err := EmbedMetadata(filepath, metadata, coverPath); err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
package backend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
type FlacInfo struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
SampleRate uint32 `json:"sample_rate"`
|
||||||
|
BitsPerSample uint8 `json:"bits_per_sample"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetFlacInfoBatch(paths []string) []FlacInfo {
|
||||||
|
results := make([]FlacInfo, len(paths))
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
for i, path := range paths {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(idx int, p string) {
|
||||||
|
defer wg.Done()
|
||||||
|
info := FlacInfo{Path: p}
|
||||||
|
|
||||||
|
ffprobePath, err := GetFFprobePath()
|
||||||
|
if err != nil {
|
||||||
|
results[idx] = info
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
args := []string{
|
||||||
|
"-v", "error",
|
||||||
|
"-select_streams", "a:0",
|
||||||
|
"-show_entries", "stream=sample_rate,bits_per_raw_sample,bits_per_sample",
|
||||||
|
"-of", "default=noprint_wrappers=0",
|
||||||
|
p,
|
||||||
|
}
|
||||||
|
cmd := exec.Command(ffprobePath, args...)
|
||||||
|
setHideWindow(cmd)
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
results[idx] = info
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
kvMap := make(map[string]string)
|
||||||
|
for _, line := range strings.Split(string(out), "\n") {
|
||||||
|
if parts := strings.SplitN(line, "=", 2); len(parts) == 2 {
|
||||||
|
kvMap[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if v, ok := kvMap["sample_rate"]; ok {
|
||||||
|
if s, err := strconv.Atoi(v); err == nil {
|
||||||
|
info.SampleRate = uint32(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bits := 0
|
||||||
|
if v, ok := kvMap["bits_per_raw_sample"]; ok && v != "N/A" && v != "" {
|
||||||
|
bits, _ = strconv.Atoi(v)
|
||||||
|
}
|
||||||
|
if bits == 0 {
|
||||||
|
if v, ok := kvMap["bits_per_sample"]; ok && v != "N/A" && v != "" {
|
||||||
|
bits, _ = strconv.Atoi(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
info.BitsPerSample = uint8(bits)
|
||||||
|
|
||||||
|
results[idx] = info
|
||||||
|
}(i, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResampleRequest struct {
|
||||||
|
InputFiles []string `json:"input_files"`
|
||||||
|
SampleRate string `json:"sample_rate"`
|
||||||
|
BitDepth string `json:"bit_depth"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResampleResult struct {
|
||||||
|
InputFile string `json:"input_file"`
|
||||||
|
OutputFile string `json:"output_file"`
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildFolderLabel(sampleRate, bitDepth string) string {
|
||||||
|
var parts []string
|
||||||
|
|
||||||
|
if bitDepth != "" {
|
||||||
|
parts = append(parts, bitDepth+"bit")
|
||||||
|
}
|
||||||
|
|
||||||
|
switch sampleRate {
|
||||||
|
case "44100":
|
||||||
|
parts = append(parts, "44.1kHz")
|
||||||
|
case "48000":
|
||||||
|
parts = append(parts, "48kHz")
|
||||||
|
case "96000":
|
||||||
|
parts = append(parts, "96kHz")
|
||||||
|
case "192000":
|
||||||
|
parts = append(parts, "192kHz")
|
||||||
|
default:
|
||||||
|
if sampleRate != "" {
|
||||||
|
parts = append(parts, sampleRate+"Hz")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(parts) == 0 {
|
||||||
|
return "Resampled"
|
||||||
|
}
|
||||||
|
return strings.Join(parts, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResampleAudio(req ResampleRequest) ([]ResampleResult, error) {
|
||||||
|
ffmpegPath, err := GetFFmpegPath()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get ffmpeg path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ValidateExecutable(ffmpegPath); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid ffmpeg executable: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
installed, err := IsFFmpegInstalled()
|
||||||
|
if err != nil || !installed {
|
||||||
|
return nil, fmt.Errorf("ffmpeg is not installed")
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.SampleRate == "" && req.BitDepth == "" {
|
||||||
|
return nil, fmt.Errorf("at least one of sample rate or bit depth must be specified")
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make([]ResampleResult, len(req.InputFiles))
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
var mu sync.Mutex
|
||||||
|
|
||||||
|
folderLabel := buildFolderLabel(req.SampleRate, req.BitDepth)
|
||||||
|
|
||||||
|
for i, inputFile := range req.InputFiles {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(idx int, inputFile string) {
|
||||||
|
defer wg.Done()
|
||||||
|
|
||||||
|
result := ResampleResult{
|
||||||
|
InputFile: inputFile,
|
||||||
|
}
|
||||||
|
|
||||||
|
inputExt := strings.ToLower(filepath.Ext(inputFile))
|
||||||
|
baseName := strings.TrimSuffix(filepath.Base(inputFile), inputExt)
|
||||||
|
inputDir := filepath.Dir(inputFile)
|
||||||
|
|
||||||
|
outputDir := filepath.Join(inputDir, folderLabel)
|
||||||
|
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||||
|
result.Error = fmt.Sprintf("failed to create output directory: %v", err)
|
||||||
|
result.Success = false
|
||||||
|
mu.Lock()
|
||||||
|
results[idx] = result
|
||||||
|
mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
outputFile := filepath.Join(outputDir, baseName+".flac")
|
||||||
|
result.OutputFile = outputFile
|
||||||
|
|
||||||
|
args := []string{
|
||||||
|
"-i", inputFile,
|
||||||
|
"-y",
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.BitDepth != "" {
|
||||||
|
switch req.BitDepth {
|
||||||
|
case "16":
|
||||||
|
args = append(args, "-c:a", "flac", "-sample_fmt", "s16")
|
||||||
|
case "24":
|
||||||
|
args = append(args, "-c:a", "flac", "-sample_fmt", "s32", "-bits_per_raw_sample", "24")
|
||||||
|
default:
|
||||||
|
args = append(args, "-c:a", "flac")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
args = append(args, "-c:a", "flac")
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.SampleRate != "" {
|
||||||
|
args = append(args, "-ar", req.SampleRate)
|
||||||
|
}
|
||||||
|
|
||||||
|
args = append(args, "-map_metadata", "0")
|
||||||
|
args = append(args, outputFile)
|
||||||
|
|
||||||
|
fmt.Printf("[Resample] %s -> %s\n", inputFile, outputFile)
|
||||||
|
|
||||||
|
cmd := exec.Command(ffmpegPath, args...)
|
||||||
|
setHideWindow(cmd)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
result.Error = fmt.Sprintf("resampling failed: %s - %s", err.Error(), string(output))
|
||||||
|
result.Success = false
|
||||||
|
mu.Lock()
|
||||||
|
results[idx] = result
|
||||||
|
mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Success = true
|
||||||
|
fmt.Printf("[Resample] Done: %s\n", outputFile)
|
||||||
|
mu.Lock()
|
||||||
|
results[idx] = result
|
||||||
|
mu.Unlock()
|
||||||
|
}(i, inputFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
+790
-319
File diff suppressed because it is too large
Load Diff
@@ -1,181 +0,0 @@
|
|||||||
package backend
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"math"
|
|
||||||
"math/cmplx"
|
|
||||||
|
|
||||||
"github.com/mewkiz/flac"
|
|
||||||
)
|
|
||||||
|
|
||||||
type SpectrumData struct {
|
|
||||||
TimeSlices []TimeSlice `json:"time_slices"`
|
|
||||||
SampleRate int `json:"sample_rate"`
|
|
||||||
FreqBins int `json:"freq_bins"`
|
|
||||||
Duration float64 `json:"duration"`
|
|
||||||
MaxFreq float64 `json:"max_freq"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type TimeSlice struct {
|
|
||||||
Time float64 `json:"time"`
|
|
||||||
Magnitudes []float64 `json:"magnitudes"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func AnalyzeSpectrum(filepath string) (*SpectrumData, error) {
|
|
||||||
|
|
||||||
stream, err := flac.ParseFile(filepath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to parse FLAC: %w", err)
|
|
||||||
}
|
|
||||||
defer stream.Close()
|
|
||||||
|
|
||||||
info := stream.Info
|
|
||||||
sampleRate := int(info.SampleRate)
|
|
||||||
channels := int(info.NChannels)
|
|
||||||
|
|
||||||
samples, err := readSamples(stream, channels)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to read samples: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(samples) == 0 {
|
|
||||||
return nil, fmt.Errorf("no audio samples found")
|
|
||||||
}
|
|
||||||
|
|
||||||
return calculateSpectrum(samples, sampleRate), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func readSamples(stream *flac.Stream, channels int) ([]float64, error) {
|
|
||||||
var allSamples []float64
|
|
||||||
maxSamples := 10 * 1024 * 1024
|
|
||||||
|
|
||||||
for {
|
|
||||||
frame, err := stream.ParseNext()
|
|
||||||
if err != nil {
|
|
||||||
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := 0; i < frame.Subframes[0].NSamples; i++ {
|
|
||||||
var sample float64
|
|
||||||
|
|
||||||
for ch := 0; ch < channels; ch++ {
|
|
||||||
sample += float64(frame.Subframes[ch].Samples[i])
|
|
||||||
}
|
|
||||||
sample /= float64(channels)
|
|
||||||
|
|
||||||
allSamples = append(allSamples, sample)
|
|
||||||
|
|
||||||
if len(allSamples) >= maxSamples {
|
|
||||||
return allSamples, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return allSamples, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func calculateSpectrum(samples []float64, sampleRate int) *SpectrumData {
|
|
||||||
fftSize := 8192
|
|
||||||
numTimeSlices := 300
|
|
||||||
|
|
||||||
duration := float64(len(samples)) / float64(sampleRate)
|
|
||||||
|
|
||||||
samplesPerSlice := len(samples) / numTimeSlices
|
|
||||||
if samplesPerSlice < fftSize {
|
|
||||||
samplesPerSlice = fftSize
|
|
||||||
numTimeSlices = len(samples) / fftSize
|
|
||||||
}
|
|
||||||
|
|
||||||
timeSlices := make([]TimeSlice, 0, numTimeSlices)
|
|
||||||
freqBins := fftSize / 2
|
|
||||||
maxFreq := float64(sampleRate) / 2.0
|
|
||||||
|
|
||||||
for i := 0; i < numTimeSlices; i++ {
|
|
||||||
startIdx := i * samplesPerSlice
|
|
||||||
if startIdx+fftSize > len(samples) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
window := samples[startIdx : startIdx+fftSize]
|
|
||||||
|
|
||||||
windowedSamples := applyHannWindow(window)
|
|
||||||
|
|
||||||
spectrum := fft(windowedSamples)
|
|
||||||
|
|
||||||
magnitudes := make([]float64, freqBins)
|
|
||||||
for j := 0; j < freqBins; j++ {
|
|
||||||
magnitude := cmplx.Abs(spectrum[j])
|
|
||||||
|
|
||||||
if magnitude < 1e-10 {
|
|
||||||
magnitude = 1e-10
|
|
||||||
}
|
|
||||||
magnitudes[j] = 20 * math.Log10(magnitude)
|
|
||||||
}
|
|
||||||
|
|
||||||
timeSlice := TimeSlice{
|
|
||||||
Time: float64(startIdx) / float64(sampleRate),
|
|
||||||
Magnitudes: magnitudes,
|
|
||||||
}
|
|
||||||
timeSlices = append(timeSlices, timeSlice)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &SpectrumData{
|
|
||||||
TimeSlices: timeSlices,
|
|
||||||
SampleRate: sampleRate,
|
|
||||||
FreqBins: freqBins,
|
|
||||||
Duration: duration,
|
|
||||||
MaxFreq: maxFreq,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func applyHannWindow(samples []float64) []float64 {
|
|
||||||
n := len(samples)
|
|
||||||
windowed := make([]float64, n)
|
|
||||||
|
|
||||||
for i := 0; i < n; i++ {
|
|
||||||
window := 0.5 * (1.0 - math.Cos(2.0*math.Pi*float64(i)/float64(n-1)))
|
|
||||||
windowed[i] = samples[i] * window
|
|
||||||
}
|
|
||||||
|
|
||||||
return windowed
|
|
||||||
}
|
|
||||||
|
|
||||||
func fft(samples []float64) []complex128 {
|
|
||||||
n := len(samples)
|
|
||||||
|
|
||||||
x := make([]complex128, n)
|
|
||||||
for i := 0; i < n; i++ {
|
|
||||||
x[i] = complex(samples[i], 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
return fftRecursive(x)
|
|
||||||
}
|
|
||||||
|
|
||||||
func fftRecursive(x []complex128) []complex128 {
|
|
||||||
n := len(x)
|
|
||||||
|
|
||||||
if n <= 1 {
|
|
||||||
return x
|
|
||||||
}
|
|
||||||
|
|
||||||
even := make([]complex128, n/2)
|
|
||||||
odd := make([]complex128, n/2)
|
|
||||||
|
|
||||||
for i := 0; i < n/2; i++ {
|
|
||||||
even[i] = x[2*i]
|
|
||||||
odd[i] = x[2*i+1]
|
|
||||||
}
|
|
||||||
|
|
||||||
evenFFT := fftRecursive(even)
|
|
||||||
oddFFT := fftRecursive(odd)
|
|
||||||
|
|
||||||
result := make([]complex128, n)
|
|
||||||
for k := 0; k < n/2; k++ {
|
|
||||||
t := cmplx.Exp(complex(0, -2*math.Pi*float64(k)/float64(n))) * oddFFT[k]
|
|
||||||
result[k] = evenFFT[k] + t
|
|
||||||
result[k+n/2] = evenFFT[k] - t
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
+57
-108
@@ -2,9 +2,7 @@ package backend
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/base32"
|
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/hex"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -41,39 +39,10 @@ func NewSpotifyClient() *SpotifyClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *SpotifyClient) getTOTPSecret() (int, []byte) {
|
|
||||||
secrets := map[int][]byte{
|
|
||||||
59: {123, 105, 79, 70, 110, 59, 52, 125, 60, 49, 80, 70, 89, 75, 80, 86, 63, 53, 123, 37, 117, 49, 52, 93, 77, 62, 47, 86, 48, 104, 68, 72},
|
|
||||||
60: {79, 109, 69, 123, 90, 65, 46, 74, 94, 34, 58, 48, 70, 71, 92, 85, 122, 63, 91, 64, 87, 87},
|
|
||||||
61: {44, 55, 47, 42, 70, 40, 34, 114, 76, 74, 50, 111, 120, 97, 75, 76, 94, 102, 43, 69, 49, 120, 118, 80, 64, 78},
|
|
||||||
}
|
|
||||||
|
|
||||||
version := 61
|
|
||||||
secretList := secrets[version]
|
|
||||||
return version, secretList
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *SpotifyClient) generateTOTP() (string, int, error) {
|
func (c *SpotifyClient) generateTOTP() (string, int, error) {
|
||||||
version, secretList := c.getTOTPSecret()
|
|
||||||
|
|
||||||
transformed := make([]byte, len(secretList))
|
secret := "GM3TMMJTGYZTQNZVGM4DINJZHA4TGOBYGMZTCMRTGEYDSMJRHE4TEOBUG4YTCMRUGQ4DQOJUGQYTAMRRGA2TCMJSHE3TCMBY"
|
||||||
for i, b := range secretList {
|
version := 61
|
||||||
transformed[i] = b ^ byte((i%33)+9)
|
|
||||||
}
|
|
||||||
|
|
||||||
var joined strings.Builder
|
|
||||||
for _, b := range transformed {
|
|
||||||
joined.WriteString(strconv.Itoa(int(b)))
|
|
||||||
}
|
|
||||||
|
|
||||||
hexStr := hex.EncodeToString([]byte(joined.String()))
|
|
||||||
hexBytes, err := hex.DecodeString(hexStr)
|
|
||||||
if err != nil {
|
|
||||||
return "", 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
secret := base32Encode(hexBytes)
|
|
||||||
secret = strings.TrimRight(secret, "=")
|
|
||||||
|
|
||||||
key, err := otp.NewKeyFromURL(fmt.Sprintf("otpauth://totp/secret?secret=%s", secret))
|
key, err := otp.NewKeyFromURL(fmt.Sprintf("otpauth://totp/secret?secret=%s", secret))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -88,11 +57,6 @@ func (c *SpotifyClient) generateTOTP() (string, int, error) {
|
|||||||
return totpCode, version, nil
|
return totpCode, version, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func base32Encode(data []byte) string {
|
|
||||||
b32 := base32.StdEncoding.WithPadding(base32.NoPadding)
|
|
||||||
return b32.EncodeToString(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *SpotifyClient) getAccessToken() error {
|
func (c *SpotifyClient) getAccessToken() error {
|
||||||
totpCode, version, err := c.generateTOTP()
|
totpCode, version, err := c.generateTOTP()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -112,7 +76,7 @@ func (c *SpotifyClient) getAccessToken() error {
|
|||||||
q.Add("totpServer", totpCode)
|
q.Add("totpServer", totpCode)
|
||||||
req.URL.RawQuery = q.Encode()
|
req.URL.RawQuery = q.Encode()
|
||||||
|
|
||||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36")
|
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36")
|
||||||
req.Header.Set("Content-Type", "application/json;charset=UTF-8")
|
req.Header.Set("Content-Type", "application/json;charset=UTF-8")
|
||||||
|
|
||||||
resp, err := c.client.Do(req)
|
resp, err := c.client.Do(req)
|
||||||
@@ -149,7 +113,7 @@ func (c *SpotifyClient) getSessionInfo() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36")
|
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36")
|
||||||
|
|
||||||
for name, value := range c.cookies {
|
for name, value := range c.cookies {
|
||||||
req.AddCookie(&http.Cookie{Name: name, Value: value})
|
req.AddCookie(&http.Cookie{Name: name, Value: value})
|
||||||
@@ -230,7 +194,7 @@ func (c *SpotifyClient) getClientToken() error {
|
|||||||
req.Header.Set("Authority", "clienttoken.spotify.com")
|
req.Header.Set("Authority", "clienttoken.spotify.com")
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
req.Header.Set("Accept", "application/json")
|
req.Header.Set("Accept", "application/json")
|
||||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36")
|
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36")
|
||||||
|
|
||||||
resp, err := c.client.Do(req)
|
resp, err := c.client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -288,7 +252,7 @@ func (c *SpotifyClient) Query(payload map[string]interface{}) (map[string]interf
|
|||||||
req.Header.Set("Client-Token", c.clientToken)
|
req.Header.Set("Client-Token", c.clientToken)
|
||||||
req.Header.Set("Spotify-App-Version", c.clientVersion)
|
req.Header.Set("Spotify-App-Version", c.clientVersion)
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36")
|
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36")
|
||||||
|
|
||||||
resp, err := c.client.Do(req)
|
resp, err := c.client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -364,9 +328,6 @@ func getBool(m map[string]interface{}, key string) bool {
|
|||||||
|
|
||||||
func extractArtists(artistsData map[string]interface{}) []map[string]interface{} {
|
func extractArtists(artistsData map[string]interface{}) []map[string]interface{} {
|
||||||
items := getSlice(artistsData, "items")
|
items := getSlice(artistsData, "items")
|
||||||
if items == nil {
|
|
||||||
return []map[string]interface{}{}
|
|
||||||
}
|
|
||||||
|
|
||||||
artists := []map[string]interface{}{}
|
artists := []map[string]interface{}{}
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
@@ -384,7 +345,7 @@ func extractArtists(artistsData map[string]interface{}) []map[string]interface{}
|
|||||||
}
|
}
|
||||||
|
|
||||||
func extractCoverImage(coverData map[string]interface{}) map[string]interface{} {
|
func extractCoverImage(coverData map[string]interface{}) map[string]interface{} {
|
||||||
if coverData == nil || len(coverData) == 0 {
|
if len(coverData) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -401,7 +362,7 @@ func extractCoverImage(coverData map[string]interface{}) map[string]interface{}
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if sources == nil || len(sources) == 0 {
|
if len(sources) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -524,7 +485,7 @@ func extractDuration(ms float64) map[string]interface{} {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func FilterTrack(data map[string]interface{}, albumFetchData ...map[string]interface{}) map[string]interface{} {
|
func FilterTrack(data map[string]interface{}, separator string, albumFetchData ...map[string]interface{}) map[string]interface{} {
|
||||||
dataMap := getMap(data, "data")
|
dataMap := getMap(data, "data")
|
||||||
trackData := getMap(dataMap, "trackUnion")
|
trackData := getMap(dataMap, "trackUnion")
|
||||||
if len(trackData) == 0 {
|
if len(trackData) == 0 {
|
||||||
@@ -532,7 +493,7 @@ func FilterTrack(data map[string]interface{}, albumFetchData ...map[string]inter
|
|||||||
}
|
}
|
||||||
|
|
||||||
var albumFetchDataMap map[string]interface{}
|
var albumFetchDataMap map[string]interface{}
|
||||||
if len(albumFetchData) > 0 && albumFetchData[0] != nil {
|
if len(albumFetchData) > 0 {
|
||||||
albumFetchDataMap = albumFetchData[0]
|
albumFetchDataMap = albumFetchData[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -541,39 +502,35 @@ func FilterTrack(data map[string]interface{}, albumFetchData ...map[string]inter
|
|||||||
if len(artists) == 0 {
|
if len(artists) == 0 {
|
||||||
artists = []map[string]interface{}{}
|
artists = []map[string]interface{}{}
|
||||||
firstArtistItems := getSlice(getMap(trackData, "firstArtist"), "items")
|
firstArtistItems := getSlice(getMap(trackData, "firstArtist"), "items")
|
||||||
if firstArtistItems != nil {
|
for _, item := range firstArtistItems {
|
||||||
for _, item := range firstArtistItems {
|
itemMap, ok := item.(map[string]interface{})
|
||||||
itemMap, ok := item.(map[string]interface{})
|
if !ok {
|
||||||
if !ok {
|
continue
|
||||||
continue
|
}
|
||||||
}
|
if profile, exists := itemMap["profile"]; exists {
|
||||||
if profile, exists := itemMap["profile"]; exists {
|
profileMap, ok := profile.(map[string]interface{})
|
||||||
profileMap, ok := profile.(map[string]interface{})
|
if ok {
|
||||||
if ok {
|
artistInfo := map[string]interface{}{
|
||||||
artistInfo := map[string]interface{}{
|
"name": getString(profileMap, "name"),
|
||||||
"name": getString(profileMap, "name"),
|
|
||||||
}
|
|
||||||
artists = append(artists, artistInfo)
|
|
||||||
}
|
}
|
||||||
|
artists = append(artists, artistInfo)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
otherArtistItems := getSlice(getMap(trackData, "otherArtists"), "items")
|
otherArtistItems := getSlice(getMap(trackData, "otherArtists"), "items")
|
||||||
if otherArtistItems != nil {
|
for _, item := range otherArtistItems {
|
||||||
for _, item := range otherArtistItems {
|
itemMap, ok := item.(map[string]interface{})
|
||||||
itemMap, ok := item.(map[string]interface{})
|
if !ok {
|
||||||
if !ok {
|
continue
|
||||||
continue
|
}
|
||||||
}
|
if profile, exists := itemMap["profile"]; exists {
|
||||||
if profile, exists := itemMap["profile"]; exists {
|
profileMap, ok := profile.(map[string]interface{})
|
||||||
profileMap, ok := profile.(map[string]interface{})
|
if ok {
|
||||||
if ok {
|
artistInfo := map[string]interface{}{
|
||||||
artistInfo := map[string]interface{}{
|
"name": getString(profileMap, "name"),
|
||||||
"name": getString(profileMap, "name"),
|
|
||||||
}
|
|
||||||
artists = append(artists, artistInfo)
|
|
||||||
}
|
}
|
||||||
|
artists = append(artists, artistInfo)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -598,7 +555,7 @@ func FilterTrack(data map[string]interface{}, albumFetchData ...map[string]inter
|
|||||||
copyrightData := getMap(albumData, "copyright")
|
copyrightData := getMap(albumData, "copyright")
|
||||||
if len(copyrightData) > 0 {
|
if len(copyrightData) > 0 {
|
||||||
copyrightItems := getSlice(copyrightData, "items")
|
copyrightItems := getSlice(copyrightData, "items")
|
||||||
if copyrightItems != nil {
|
if len(copyrightItems) > 0 {
|
||||||
for _, item := range copyrightItems {
|
for _, item := range copyrightItems {
|
||||||
itemMap, ok := item.(map[string]interface{})
|
itemMap, ok := item.(map[string]interface{})
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -617,7 +574,7 @@ func FilterTrack(data map[string]interface{}, albumFetchData ...map[string]inter
|
|||||||
if len(tracksData) > 0 {
|
if len(tracksData) > 0 {
|
||||||
discNumbers := make(map[int]bool)
|
discNumbers := make(map[int]bool)
|
||||||
trackItems := getSlice(tracksData, "items")
|
trackItems := getSlice(tracksData, "items")
|
||||||
if trackItems != nil {
|
if len(trackItems) > 0 {
|
||||||
for _, item := range trackItems {
|
for _, item := range trackItems {
|
||||||
itemMap, ok := item.(map[string]interface{})
|
itemMap, ok := item.(map[string]interface{})
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -699,7 +656,7 @@ func FilterTrack(data map[string]interface{}, albumFetchData ...map[string]inter
|
|||||||
|
|
||||||
albumArtistsString := ""
|
albumArtistsString := ""
|
||||||
albumLabel := ""
|
albumLabel := ""
|
||||||
if albumFetchDataMap != nil && len(albumFetchDataMap) > 0 {
|
if len(albumFetchDataMap) > 0 {
|
||||||
albumUnionData := getMap(getMap(albumFetchDataMap, "data"), "albumUnion")
|
albumUnionData := getMap(getMap(albumFetchDataMap, "data"), "albumUnion")
|
||||||
if len(albumUnionData) > 0 {
|
if len(albumUnionData) > 0 {
|
||||||
albumArtists := extractArtists(getMap(albumUnionData, "artists"))
|
albumArtists := extractArtists(getMap(albumUnionData, "artists"))
|
||||||
@@ -708,7 +665,10 @@ func FilterTrack(data map[string]interface{}, albumFetchData ...map[string]inter
|
|||||||
for _, artist := range albumArtists {
|
for _, artist := range albumArtists {
|
||||||
albumArtistNames = append(albumArtistNames, getString(artist, "name"))
|
albumArtistNames = append(albumArtistNames, getString(artist, "name"))
|
||||||
}
|
}
|
||||||
albumArtistsString = strings.Join(albumArtistNames, ", ")
|
albumArtistsString = strings.Join(albumArtistNames, separator)
|
||||||
|
}
|
||||||
|
if albumArtistsString == "" {
|
||||||
|
albumArtistsString = getString(albumUnionData, "artists")
|
||||||
}
|
}
|
||||||
albumLabel = getString(albumUnionData, "label")
|
albumLabel = getString(albumUnionData, "label")
|
||||||
}
|
}
|
||||||
@@ -721,7 +681,7 @@ func FilterTrack(data map[string]interface{}, albumFetchData ...map[string]inter
|
|||||||
for _, artist := range albumArtists {
|
for _, artist := range albumArtists {
|
||||||
albumArtistNames = append(albumArtistNames, getString(artist, "name"))
|
albumArtistNames = append(albumArtistNames, getString(artist, "name"))
|
||||||
}
|
}
|
||||||
albumArtistsString = strings.Join(albumArtistNames, ", ")
|
albumArtistsString = strings.Join(albumArtistNames, separator)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -755,13 +715,13 @@ func FilterTrack(data map[string]interface{}, albumFetchData ...map[string]inter
|
|||||||
for _, artist := range artists {
|
for _, artist := range artists {
|
||||||
artistNames = append(artistNames, getString(artist, "name"))
|
artistNames = append(artistNames, getString(artist, "name"))
|
||||||
}
|
}
|
||||||
artistsString := strings.Join(artistNames, ", ")
|
artistsString := strings.Join(artistNames, separator)
|
||||||
|
|
||||||
copyrightTexts := []string{}
|
copyrightTexts := []string{}
|
||||||
for _, item := range copyrightInfo {
|
for _, item := range copyrightInfo {
|
||||||
copyrightTexts = append(copyrightTexts, getString(item, "text"))
|
copyrightTexts = append(copyrightTexts, getString(item, "text"))
|
||||||
}
|
}
|
||||||
copyrightString := strings.Join(copyrightTexts, ", ")
|
copyrightString := strings.Join(copyrightTexts, GetSeparator())
|
||||||
|
|
||||||
discNumber := int(getFloat64(trackData, "discNumber"))
|
discNumber := int(getFloat64(trackData, "discNumber"))
|
||||||
if discNumber == 0 {
|
if discNumber == 0 {
|
||||||
@@ -842,7 +802,7 @@ func FilterTrack(data map[string]interface{}, albumFetchData ...map[string]inter
|
|||||||
return filtered
|
return filtered
|
||||||
}
|
}
|
||||||
|
|
||||||
func FilterAlbum(data map[string]interface{}) map[string]interface{} {
|
func FilterAlbum(data map[string]interface{}, separator string) map[string]interface{} {
|
||||||
dataMap := getMap(data, "data")
|
dataMap := getMap(data, "data")
|
||||||
albumData := getMap(dataMap, "albumUnion")
|
albumData := getMap(dataMap, "albumUnion")
|
||||||
if len(albumData) == 0 {
|
if len(albumData) == 0 {
|
||||||
@@ -854,7 +814,7 @@ func FilterAlbum(data map[string]interface{}) map[string]interface{} {
|
|||||||
for _, artist := range artists {
|
for _, artist := range artists {
|
||||||
artistNames = append(artistNames, getString(artist, "name"))
|
artistNames = append(artistNames, getString(artist, "name"))
|
||||||
}
|
}
|
||||||
albumArtistsString := strings.Join(artistNames, ", ")
|
albumArtistsString := strings.Join(artistNames, separator)
|
||||||
|
|
||||||
coverObj := extractCoverImage(getMap(albumData, "coverArt"))
|
coverObj := extractCoverImage(getMap(albumData, "coverArt"))
|
||||||
var cover interface{}
|
var cover interface{}
|
||||||
@@ -915,7 +875,7 @@ func FilterAlbum(data map[string]interface{}) map[string]interface{} {
|
|||||||
for _, artist := range trackArtists {
|
for _, artist := range trackArtists {
|
||||||
trackArtistNames = append(trackArtistNames, getString(artist, "name"))
|
trackArtistNames = append(trackArtistNames, getString(artist, "name"))
|
||||||
}
|
}
|
||||||
trackArtistsString := strings.Join(trackArtistNames, ", ")
|
trackArtistsString := strings.Join(trackArtistNames, separator)
|
||||||
|
|
||||||
trackURI := getString(track, "uri")
|
trackURI := getString(track, "uri")
|
||||||
trackID := ""
|
trackID := ""
|
||||||
@@ -977,12 +937,13 @@ func FilterAlbum(data map[string]interface{}) map[string]interface{} {
|
|||||||
"discs": map[string]interface{}{
|
"discs": map[string]interface{}{
|
||||||
"totalCount": totalDiscs,
|
"totalCount": totalDiscs,
|
||||||
},
|
},
|
||||||
|
"label": getString(albumData, "label"),
|
||||||
}
|
}
|
||||||
|
|
||||||
return filtered
|
return filtered
|
||||||
}
|
}
|
||||||
|
|
||||||
func FilterPlaylist(data map[string]interface{}) map[string]interface{} {
|
func FilterPlaylist(data map[string]interface{}, separator string) map[string]interface{} {
|
||||||
dataMap := getMap(data, "data")
|
dataMap := getMap(data, "data")
|
||||||
playlistData := getMap(dataMap, "playlistV2")
|
playlistData := getMap(dataMap, "playlistV2")
|
||||||
if len(playlistData) == 0 {
|
if len(playlistData) == 0 {
|
||||||
@@ -996,21 +957,9 @@ func FilterPlaylist(data map[string]interface{}) map[string]interface{} {
|
|||||||
avatarData := getMap(ownerData, "avatar")
|
avatarData := getMap(ownerData, "avatar")
|
||||||
if len(avatarData) > 0 {
|
if len(avatarData) > 0 {
|
||||||
sources := getSlice(avatarData, "sources")
|
sources := getSlice(avatarData, "sources")
|
||||||
if sources != nil {
|
if len(sources) > 0 {
|
||||||
for _, source := range sources {
|
if firstSource, ok := sources[0].(map[string]interface{}); ok {
|
||||||
sourceMap, ok := source.(map[string]interface{})
|
avatarURL = getString(firstSource, "url")
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if getFloat64(sourceMap, "width") == 300 {
|
|
||||||
avatarURL = getString(sourceMap, "url")
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if avatarURL == nil && len(sources) > 0 {
|
|
||||||
if firstSource, ok := sources[0].(map[string]interface{}); ok {
|
|
||||||
avatarURL = getString(firstSource, "url")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1114,7 +1063,7 @@ func FilterPlaylist(data map[string]interface{}) map[string]interface{} {
|
|||||||
for _, artist := range trackArtists {
|
for _, artist := range trackArtists {
|
||||||
trackArtistNames = append(trackArtistNames, getString(artist, "name"))
|
trackArtistNames = append(trackArtistNames, getString(artist, "name"))
|
||||||
}
|
}
|
||||||
artistsString := strings.Join(trackArtistNames, ", ")
|
artistsString := strings.Join(trackArtistNames, separator)
|
||||||
|
|
||||||
trackDurationMs := getFloat64(getMap(trackData, "trackDuration"), "totalMilliseconds")
|
trackDurationMs := getFloat64(getMap(trackData, "trackDuration"), "totalMilliseconds")
|
||||||
durationObj := extractDuration(trackDurationMs)
|
durationObj := extractDuration(trackDurationMs)
|
||||||
@@ -1160,7 +1109,7 @@ func FilterPlaylist(data map[string]interface{}) map[string]interface{} {
|
|||||||
for _, artist := range albumArtists {
|
for _, artist := range albumArtists {
|
||||||
albumArtistNames = append(albumArtistNames, getString(artist, "name"))
|
albumArtistNames = append(albumArtistNames, getString(artist, "name"))
|
||||||
}
|
}
|
||||||
albumArtistsString = strings.Join(albumArtistNames, ", ")
|
albumArtistsString = strings.Join(albumArtistNames, separator)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1330,11 +1279,11 @@ func extractDiscographyItems(itemsData map[string]interface{}) []map[string]inte
|
|||||||
}
|
}
|
||||||
|
|
||||||
func stripHTMLTags(s string) string {
|
func stripHTMLTags(s string) string {
|
||||||
re := regexp.MustCompile(`<[^>]*>`)
|
re := regexp.MustCompile(`(?s)<[^>]*>`)
|
||||||
return re.ReplaceAllString(s, "")
|
return re.ReplaceAllString(s, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
func FilterArtist(data map[string]interface{}) map[string]interface{} {
|
func FilterArtist(data map[string]interface{}, separator string) map[string]interface{} {
|
||||||
dataMap := getMap(data, "data")
|
dataMap := getMap(data, "data")
|
||||||
artistData := getMap(dataMap, "artistUnion")
|
artistData := getMap(dataMap, "artistUnion")
|
||||||
if len(artistData) == 0 {
|
if len(artistData) == 0 {
|
||||||
@@ -1463,7 +1412,7 @@ func FilterArtist(data map[string]interface{}) map[string]interface{} {
|
|||||||
return filtered
|
return filtered
|
||||||
}
|
}
|
||||||
|
|
||||||
func FilterSearch(data map[string]interface{}) map[string]interface{} {
|
func FilterSearch(data map[string]interface{}, separator string) map[string]interface{} {
|
||||||
dataMap := getMap(data, "data")
|
dataMap := getMap(data, "data")
|
||||||
searchData := getMap(dataMap, "searchV2")
|
searchData := getMap(dataMap, "searchV2")
|
||||||
if len(searchData) == 0 {
|
if len(searchData) == 0 {
|
||||||
@@ -1553,7 +1502,7 @@ func FilterSearch(data map[string]interface{}) map[string]interface{} {
|
|||||||
for _, artist := range trackArtists {
|
for _, artist := range trackArtists {
|
||||||
trackArtistNames = append(trackArtistNames, getString(artist, "name"))
|
trackArtistNames = append(trackArtistNames, getString(artist, "name"))
|
||||||
}
|
}
|
||||||
trackArtistsString := strings.Join(trackArtistNames, ", ")
|
trackArtistsString := strings.Join(trackArtistNames, separator)
|
||||||
|
|
||||||
durationString := getString(trackDuration, "formatted")
|
durationString := getString(trackDuration, "formatted")
|
||||||
|
|
||||||
@@ -1625,7 +1574,7 @@ func FilterSearch(data map[string]interface{}) map[string]interface{} {
|
|||||||
for _, artist := range albumArtists {
|
for _, artist := range albumArtists {
|
||||||
albumArtistNames = append(albumArtistNames, getString(artist, "name"))
|
albumArtistNames = append(albumArtistNames, getString(artist, "name"))
|
||||||
}
|
}
|
||||||
albumArtistsString := strings.Join(albumArtistNames, ", ")
|
albumArtistsString := strings.Join(albumArtistNames, separator)
|
||||||
|
|
||||||
dateInfo := getMap(album, "date")
|
dateInfo := getMap(album, "date")
|
||||||
var year interface{}
|
var year interface{}
|
||||||
|
|||||||
@@ -11,10 +11,37 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func GetSpotifyDataWithAPI(ctx context.Context, spotifyURL string, useAPI bool, apiBaseURL string, batch bool, delay time.Duration) (interface{}, error) {
|
func streamTrackListChunks(ctx context.Context, tracks []AlbumTrackMetadata, callback MetadataCallback) error {
|
||||||
if !useAPI || apiBaseURL == "" {
|
if callback == nil || len(tracks) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
return GetFilteredSpotifyData(ctx, spotifyURL, batch, delay)
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
spotifyType, id := parseSpotifyURLToTypeAndID(spotifyURL)
|
spotifyType, id := parseSpotifyURLToTypeAndID(spotifyURL)
|
||||||
@@ -22,6 +49,10 @@ func GetSpotifyDataWithAPI(ctx context.Context, spotifyURL string, useAPI bool,
|
|||||||
return nil, fmt.Errorf("invalid Spotify URL: %s", spotifyURL)
|
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)
|
apiURL := fmt.Sprintf("%s/%s/%s", strings.TrimSuffix(apiBaseURL, "/"), spotifyType, id)
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
|
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
|
||||||
@@ -63,22 +94,75 @@ func GetSpotifyDataWithAPI(ctx context.Context, spotifyURL string, useAPI bool,
|
|||||||
return nil, fmt.Errorf("failed to decode album response: %w", err)
|
return nil, fmt.Errorf("failed to decode album response: %w", err)
|
||||||
}
|
}
|
||||||
data = &albumResp
|
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":
|
case "playlist":
|
||||||
var playlistResp PlaylistResponsePayload
|
var playlistResp PlaylistResponsePayload
|
||||||
if err := json.Unmarshal(bodyBytes, &playlistResp); err != nil {
|
if err := json.Unmarshal(bodyBytes, &playlistResp); err != nil {
|
||||||
return nil, fmt.Errorf("failed to decode playlist response: %w", err)
|
return nil, fmt.Errorf("failed to decode playlist response: %w", err)
|
||||||
}
|
}
|
||||||
data = playlistResp
|
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":
|
case "artist":
|
||||||
var artistResp ArtistDiscographyPayload
|
var artistResp ArtistDiscographyPayload
|
||||||
if err := json.Unmarshal(bodyBytes, &artistResp); err != nil {
|
if err := json.Unmarshal(bodyBytes, &artistResp); err != nil {
|
||||||
return nil, fmt.Errorf("failed to decode artist response: %w", err)
|
return nil, fmt.Errorf("failed to decode artist response: %w", err)
|
||||||
}
|
}
|
||||||
data = &artistResp
|
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:
|
default:
|
||||||
return nil, fmt.Errorf("unsupported Spotify type: %s", spotifyType)
|
return nil, fmt.Errorf("unsupported Spotify type: %s", spotifyType)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if callback != nil {
|
||||||
|
switch payload := data.(type) {
|
||||||
|
case TrackResponse:
|
||||||
|
t := payload.Track
|
||||||
|
callback([]AlbumTrackMetadata{{
|
||||||
|
SpotifyID: t.SpotifyID,
|
||||||
|
Artists: t.Artists,
|
||||||
|
Name: t.Name,
|
||||||
|
AlbumName: t.AlbumName,
|
||||||
|
AlbumArtist: t.AlbumArtist,
|
||||||
|
DurationMS: t.DurationMS,
|
||||||
|
Images: t.Images,
|
||||||
|
ReleaseDate: t.ReleaseDate,
|
||||||
|
TrackNumber: t.TrackNumber,
|
||||||
|
TotalTracks: t.TotalTracks,
|
||||||
|
DiscNumber: t.DiscNumber,
|
||||||
|
TotalDiscs: t.TotalDiscs,
|
||||||
|
ExternalURL: t.ExternalURL,
|
||||||
|
Plays: t.Plays,
|
||||||
|
PreviewURL: t.PreviewURL,
|
||||||
|
IsExplicit: t.IsExplicit,
|
||||||
|
}})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return data, nil
|
return data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+103
-36
@@ -18,13 +18,17 @@ var (
|
|||||||
errInvalidSpotifyURL = errors.New("invalid or unsupported Spotify URL")
|
errInvalidSpotifyURL = errors.New("invalid or unsupported Spotify URL")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type MetadataCallback func(data interface{})
|
||||||
|
|
||||||
type SpotifyMetadataClient struct {
|
type SpotifyMetadataClient struct {
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
|
Separator string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSpotifyMetadataClient() *SpotifyMetadataClient {
|
func NewSpotifyMetadataClient() *SpotifyMetadataClient {
|
||||||
return &SpotifyMetadataClient{
|
return &SpotifyMetadataClient{
|
||||||
httpClient: &http.Client{Timeout: 30 * time.Second},
|
httpClient: &http.Client{Timeout: 30 * time.Second},
|
||||||
|
Separator: ", ",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,7 +46,6 @@ type TrackMetadata struct {
|
|||||||
DiscNumber int `json:"disc_number,omitempty"`
|
DiscNumber int `json:"disc_number,omitempty"`
|
||||||
TotalDiscs int `json:"total_discs,omitempty"`
|
TotalDiscs int `json:"total_discs,omitempty"`
|
||||||
ExternalURL string `json:"external_urls"`
|
ExternalURL string `json:"external_urls"`
|
||||||
ISRC string `json:"isrc"`
|
|
||||||
Copyright string `json:"copyright,omitempty"`
|
Copyright string `json:"copyright,omitempty"`
|
||||||
Publisher string `json:"publisher,omitempty"`
|
Publisher string `json:"publisher,omitempty"`
|
||||||
Plays string `json:"plays,omitempty"`
|
Plays string `json:"plays,omitempty"`
|
||||||
@@ -70,7 +73,6 @@ type AlbumTrackMetadata struct {
|
|||||||
DiscNumber int `json:"disc_number,omitempty"`
|
DiscNumber int `json:"disc_number,omitempty"`
|
||||||
TotalDiscs int `json:"total_discs,omitempty"`
|
TotalDiscs int `json:"total_discs,omitempty"`
|
||||||
ExternalURL string `json:"external_urls"`
|
ExternalURL string `json:"external_urls"`
|
||||||
ISRC string `json:"isrc"`
|
|
||||||
AlbumType string `json:"album_type,omitempty"`
|
AlbumType string `json:"album_type,omitempty"`
|
||||||
AlbumID string `json:"album_id,omitempty"`
|
AlbumID string `json:"album_id,omitempty"`
|
||||||
AlbumURL string `json:"album_url,omitempty"`
|
AlbumURL string `json:"album_url,omitempty"`
|
||||||
@@ -210,6 +212,7 @@ type apiAlbumResponse struct {
|
|||||||
Cover string `json:"cover"`
|
Cover string `json:"cover"`
|
||||||
ReleaseDate string `json:"releaseDate"`
|
ReleaseDate string `json:"releaseDate"`
|
||||||
Count int `json:"count"`
|
Count int `json:"count"`
|
||||||
|
Label string `json:"label"`
|
||||||
Discs struct {
|
Discs struct {
|
||||||
TotalCount int `json:"totalCount"`
|
TotalCount int `json:"totalCount"`
|
||||||
} `json:"discs"`
|
} `json:"discs"`
|
||||||
@@ -343,54 +346,57 @@ type SearchResponse struct {
|
|||||||
Playlists []SearchResult `json:"playlists"`
|
Playlists []SearchResult `json:"playlists"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetFilteredSpotifyData(ctx context.Context, spotifyURL string, batch bool, delay time.Duration) (interface{}, error) {
|
func GetFilteredSpotifyData(ctx context.Context, spotifyURL string, batch bool, delay time.Duration, separator string, callback MetadataCallback) (interface{}, error) {
|
||||||
client := NewSpotifyMetadataClient()
|
client := NewSpotifyMetadataClient()
|
||||||
return client.GetFilteredData(ctx, spotifyURL, batch, delay)
|
if separator != "" {
|
||||||
|
client.Separator = separator
|
||||||
|
}
|
||||||
|
return client.GetFilteredData(ctx, spotifyURL, batch, delay, callback)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *SpotifyMetadataClient) GetFilteredData(ctx context.Context, spotifyURL string, batch bool, delay time.Duration) (interface{}, error) {
|
func (c *SpotifyMetadataClient) GetFilteredData(ctx context.Context, spotifyURL string, batch bool, delay time.Duration, callback MetadataCallback) (interface{}, error) {
|
||||||
parsed, err := parseSpotifyURI(spotifyURL)
|
parsed, err := parseSpotifyURI(spotifyURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
raw, err := c.getRawSpotifyData(ctx, parsed, batch, delay)
|
raw, err := c.getRawSpotifyData(ctx, parsed, batch, delay, callback)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.processSpotifyData(ctx, raw)
|
return c.processSpotifyData(ctx, raw, callback)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *SpotifyMetadataClient) getRawSpotifyData(ctx context.Context, parsed spotifyURI, batch bool, delay time.Duration) (interface{}, error) {
|
func (c *SpotifyMetadataClient) getRawSpotifyData(ctx context.Context, parsed spotifyURI, batch bool, delay time.Duration, callback MetadataCallback) (interface{}, error) {
|
||||||
switch parsed.Type {
|
switch parsed.Type {
|
||||||
case "playlist":
|
case "playlist":
|
||||||
return c.fetchPlaylist(ctx, parsed.ID)
|
return c.fetchPlaylist(ctx, parsed.ID, callback)
|
||||||
case "album":
|
case "album":
|
||||||
return c.fetchAlbum(ctx, parsed.ID)
|
return c.fetchAlbum(ctx, parsed.ID, callback)
|
||||||
case "track":
|
case "track":
|
||||||
return c.fetchTrack(ctx, parsed.ID)
|
return c.fetchTrack(ctx, parsed.ID)
|
||||||
case "artist_discography":
|
case "artist_discography":
|
||||||
return c.fetchArtistDiscography(ctx, parsed)
|
return c.fetchArtistDiscography(ctx, parsed, callback)
|
||||||
case "artist":
|
case "artist":
|
||||||
|
|
||||||
discographyParsed := spotifyURI{Type: "artist_discography", ID: parsed.ID, DiscographyGroup: "all"}
|
discographyParsed := spotifyURI{Type: "artist_discography", ID: parsed.ID, DiscographyGroup: "all"}
|
||||||
return c.fetchArtistDiscography(ctx, discographyParsed)
|
return c.fetchArtistDiscography(ctx, discographyParsed, callback)
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("unsupported Spotify type: %s", parsed.Type)
|
return nil, fmt.Errorf("unsupported Spotify type: %s", parsed.Type)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *SpotifyMetadataClient) processSpotifyData(ctx context.Context, raw interface{}) (interface{}, error) {
|
func (c *SpotifyMetadataClient) processSpotifyData(ctx context.Context, raw interface{}, callback MetadataCallback) (interface{}, error) {
|
||||||
switch payload := raw.(type) {
|
switch payload := raw.(type) {
|
||||||
case *apiPlaylistResponse:
|
case *apiPlaylistResponse:
|
||||||
return c.formatPlaylistData(payload), nil
|
return c.formatPlaylistData(payload, callback), nil
|
||||||
case *apiAlbumResponse:
|
case *apiAlbumResponse:
|
||||||
return c.formatAlbumData(payload)
|
return c.formatAlbumData(payload, callback)
|
||||||
case *apiTrackResponse:
|
case *apiTrackResponse:
|
||||||
return c.formatTrackData(payload), nil
|
return c.formatTrackData(payload), nil
|
||||||
case *apiArtistResponse:
|
case *apiArtistResponse:
|
||||||
return c.formatArtistDiscographyData(ctx, payload)
|
return c.formatArtistDiscographyData(ctx, payload, callback)
|
||||||
default:
|
default:
|
||||||
return nil, errors.New("unknown raw payload type")
|
return nil, errors.New("unknown raw payload type")
|
||||||
}
|
}
|
||||||
@@ -438,7 +444,7 @@ func (c *SpotifyMetadataClient) fetchTrack(ctx context.Context, trackID string)
|
|||||||
|
|
||||||
if albumID != "" {
|
if albumID != "" {
|
||||||
|
|
||||||
albumResponse, err := c.fetchAlbumWithClient(ctx, client, albumID)
|
albumResponse, err := c.fetchAlbumWithClient(ctx, client, albumID, nil)
|
||||||
if err == nil && albumResponse != nil {
|
if err == nil && albumResponse != nil {
|
||||||
|
|
||||||
albumJSON, _ := json.Marshal(albumResponse)
|
albumJSON, _ := json.Marshal(albumResponse)
|
||||||
@@ -472,6 +478,8 @@ func (c *SpotifyMetadataClient) fetchTrack(ctx context.Context, trackID string)
|
|||||||
"items": tracksItems,
|
"items": tracksItems,
|
||||||
"totalCount": albumResponse.Count,
|
"totalCount": albumResponse.Count,
|
||||||
},
|
},
|
||||||
|
"artists": albumResponse.Artists,
|
||||||
|
"label": albumResponse.Label,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -481,7 +489,7 @@ func (c *SpotifyMetadataClient) fetchTrack(ctx context.Context, trackID string)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
filteredData := FilterTrack(data, albumFetchData)
|
filteredData := FilterTrack(data, c.Separator, albumFetchData)
|
||||||
|
|
||||||
jsonData, err := json.Marshal(filteredData)
|
jsonData, err := json.Marshal(filteredData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -496,15 +504,15 @@ func (c *SpotifyMetadataClient) fetchTrack(ctx context.Context, trackID string)
|
|||||||
return &result, nil
|
return &result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID string) (*apiAlbumResponse, error) {
|
func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID string, callback MetadataCallback) (*apiAlbumResponse, error) {
|
||||||
client := NewSpotifyClient()
|
client := NewSpotifyClient()
|
||||||
if err := client.Initialize(); err != nil {
|
if err := client.Initialize(); err != nil {
|
||||||
return nil, fmt.Errorf("failed to initialize spotify client: %w", err)
|
return nil, fmt.Errorf("failed to initialize spotify client: %w", err)
|
||||||
}
|
}
|
||||||
return c.fetchAlbumWithClient(ctx, client, albumID)
|
return c.fetchAlbumWithClient(ctx, client, albumID, callback)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *SpotifyMetadataClient) fetchAlbumWithClient(ctx context.Context, client *SpotifyClient, albumID string) (*apiAlbumResponse, error) {
|
func (c *SpotifyMetadataClient) fetchAlbumWithClient(ctx context.Context, client *SpotifyClient, albumID string, callback MetadataCallback) (*apiAlbumResponse, error) {
|
||||||
|
|
||||||
allItems := []interface{}{}
|
allItems := []interface{}{}
|
||||||
offset := 0
|
offset := 0
|
||||||
@@ -536,6 +544,15 @@ func (c *SpotifyMetadataClient) fetchAlbumWithClient(ctx context.Context, client
|
|||||||
|
|
||||||
if data == nil {
|
if data == nil {
|
||||||
data = response
|
data = response
|
||||||
|
if callback != nil {
|
||||||
|
filtered := FilterAlbum(data, c.Separator)
|
||||||
|
jsonData, _ := json.Marshal(filtered)
|
||||||
|
var result apiAlbumResponse
|
||||||
|
if json.Unmarshal(jsonData, &result) == nil {
|
||||||
|
formatted, _ := c.formatAlbumData(&result, nil)
|
||||||
|
callback(formatted)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
albumData := getMap(getMap(response, "data"), "albumUnion")
|
albumData := getMap(getMap(response, "data"), "albumUnion")
|
||||||
@@ -578,7 +595,7 @@ func (c *SpotifyMetadataClient) fetchAlbumWithClient(ctx context.Context, client
|
|||||||
tracksV2["totalCount"] = len(allItems)
|
tracksV2["totalCount"] = len(allItems)
|
||||||
}
|
}
|
||||||
|
|
||||||
filteredData := FilterAlbum(data)
|
filteredData := FilterAlbum(data, c.Separator)
|
||||||
|
|
||||||
jsonData, err := json.Marshal(filteredData)
|
jsonData, err := json.Marshal(filteredData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -593,7 +610,7 @@ func (c *SpotifyMetadataClient) fetchAlbumWithClient(ctx context.Context, client
|
|||||||
return &result, nil
|
return &result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID string) (*apiPlaylistResponse, error) {
|
func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID string, callback MetadataCallback) (*apiPlaylistResponse, error) {
|
||||||
client := NewSpotifyClient()
|
client := NewSpotifyClient()
|
||||||
if err := client.Initialize(); err != nil {
|
if err := client.Initialize(); err != nil {
|
||||||
return nil, fmt.Errorf("failed to initialize spotify client: %w", err)
|
return nil, fmt.Errorf("failed to initialize spotify client: %w", err)
|
||||||
@@ -629,6 +646,15 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID st
|
|||||||
|
|
||||||
if data == nil {
|
if data == nil {
|
||||||
data = response
|
data = response
|
||||||
|
if callback != nil {
|
||||||
|
filtered := FilterPlaylist(data, c.Separator)
|
||||||
|
jsonData, _ := json.Marshal(filtered)
|
||||||
|
var result apiPlaylistResponse
|
||||||
|
if json.Unmarshal(jsonData, &result) == nil {
|
||||||
|
formatted := c.formatPlaylistData(&result, nil)
|
||||||
|
callback(formatted)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
playlistData := getMap(getMap(response, "data"), "playlistV2")
|
playlistData := getMap(getMap(response, "data"), "playlistV2")
|
||||||
@@ -671,7 +697,7 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID st
|
|||||||
content["totalCount"] = len(allItems)
|
content["totalCount"] = len(allItems)
|
||||||
}
|
}
|
||||||
|
|
||||||
filteredData := FilterPlaylist(data)
|
filteredData := FilterPlaylist(data, c.Separator)
|
||||||
|
|
||||||
jsonData, err := json.Marshal(filteredData)
|
jsonData, err := json.Marshal(filteredData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -686,7 +712,7 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID st
|
|||||||
return &result, nil
|
return &result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *SpotifyMetadataClient) fetchArtistDiscography(ctx context.Context, parsed spotifyURI) (*apiArtistResponse, error) {
|
func (c *SpotifyMetadataClient) fetchArtistDiscography(ctx context.Context, parsed spotifyURI, callback MetadataCallback) (*apiArtistResponse, error) {
|
||||||
client := NewSpotifyClient()
|
client := NewSpotifyClient()
|
||||||
if err := client.Initialize(); err != nil {
|
if err := client.Initialize(); err != nil {
|
||||||
return nil, fmt.Errorf("failed to initialize spotify client: %w", err)
|
return nil, fmt.Errorf("failed to initialize spotify client: %w", err)
|
||||||
@@ -711,6 +737,16 @@ func (c *SpotifyMetadataClient) fetchArtistDiscography(ctx context.Context, pars
|
|||||||
return nil, fmt.Errorf("failed to query artist overview: %w", err)
|
return nil, fmt.Errorf("failed to query artist overview: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if callback != nil {
|
||||||
|
filtered := FilterArtist(data, c.Separator)
|
||||||
|
jsonData, _ := json.Marshal(filtered)
|
||||||
|
var result apiArtistResponse
|
||||||
|
if json.Unmarshal(jsonData, &result) == nil {
|
||||||
|
formatted, _ := c.formatArtistDiscographyData(ctx, &result, nil)
|
||||||
|
callback(formatted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
allDiscographyItems := []interface{}{}
|
allDiscographyItems := []interface{}{}
|
||||||
offset := 0
|
offset := 0
|
||||||
limit := 50
|
limit := 50
|
||||||
@@ -840,7 +876,7 @@ func (c *SpotifyMetadataClient) fetchArtistDiscography(ctx context.Context, pars
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
filteredData := FilterArtist(data)
|
filteredData := FilterArtist(data, c.Separator)
|
||||||
|
|
||||||
jsonData, err := json.Marshal(filteredData)
|
jsonData, err := json.Marshal(filteredData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -886,7 +922,6 @@ func (c *SpotifyMetadataClient) formatTrackData(raw *apiTrackResponse) TrackResp
|
|||||||
DiscNumber: raw.Disc,
|
DiscNumber: raw.Disc,
|
||||||
TotalDiscs: raw.Discs,
|
TotalDiscs: raw.Discs,
|
||||||
ExternalURL: externalURL,
|
ExternalURL: externalURL,
|
||||||
ISRC: raw.ID,
|
|
||||||
Copyright: raw.Copyright,
|
Copyright: raw.Copyright,
|
||||||
Publisher: raw.Album.Label,
|
Publisher: raw.Album.Label,
|
||||||
Plays: raw.Plays,
|
Plays: raw.Plays,
|
||||||
@@ -898,7 +933,7 @@ func (c *SpotifyMetadataClient) formatTrackData(raw *apiTrackResponse) TrackResp
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *SpotifyMetadataClient) formatAlbumData(raw *apiAlbumResponse) (*AlbumResponsePayload, error) {
|
func (c *SpotifyMetadataClient) formatAlbumData(raw *apiAlbumResponse, callback MetadataCallback) (*AlbumResponsePayload, error) {
|
||||||
var artistID, artistURL string
|
var artistID, artistURL string
|
||||||
|
|
||||||
info := AlbumInfoMetadata{
|
info := AlbumInfoMetadata{
|
||||||
@@ -911,6 +946,13 @@ func (c *SpotifyMetadataClient) formatAlbumData(raw *apiAlbumResponse) (*AlbumRe
|
|||||||
ArtistURL: artistURL,
|
ArtistURL: artistURL,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if callback != nil {
|
||||||
|
callback(AlbumResponsePayload{
|
||||||
|
AlbumInfo: info,
|
||||||
|
TrackList: []AlbumTrackMetadata{},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
tracks := make([]AlbumTrackMetadata, 0, len(raw.Tracks))
|
tracks := make([]AlbumTrackMetadata, 0, len(raw.Tracks))
|
||||||
for idx, item := range raw.Tracks {
|
for idx, item := range raw.Tracks {
|
||||||
durationMS := parseDuration(item.Duration)
|
durationMS := parseDuration(item.Duration)
|
||||||
@@ -945,7 +987,6 @@ func (c *SpotifyMetadataClient) formatAlbumData(raw *apiAlbumResponse) (*AlbumRe
|
|||||||
DiscNumber: item.DiscNumber,
|
DiscNumber: item.DiscNumber,
|
||||||
TotalDiscs: raw.Discs.TotalCount,
|
TotalDiscs: raw.Discs.TotalCount,
|
||||||
ExternalURL: fmt.Sprintf("https://open.spotify.com/track/%s", item.ID),
|
ExternalURL: fmt.Sprintf("https://open.spotify.com/track/%s", item.ID),
|
||||||
ISRC: item.ID,
|
|
||||||
AlbumID: raw.ID,
|
AlbumID: raw.ID,
|
||||||
AlbumURL: fmt.Sprintf("https://open.spotify.com/album/%s", raw.ID),
|
AlbumURL: fmt.Sprintf("https://open.spotify.com/album/%s", raw.ID),
|
||||||
ArtistID: artistID,
|
ArtistID: artistID,
|
||||||
@@ -956,13 +997,17 @@ func (c *SpotifyMetadataClient) formatAlbumData(raw *apiAlbumResponse) (*AlbumRe
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if callback != nil {
|
||||||
|
callback(tracks)
|
||||||
|
}
|
||||||
|
|
||||||
return &AlbumResponsePayload{
|
return &AlbumResponsePayload{
|
||||||
AlbumInfo: info,
|
AlbumInfo: info,
|
||||||
TrackList: tracks,
|
TrackList: tracks,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *SpotifyMetadataClient) formatPlaylistData(raw *apiPlaylistResponse) PlaylistResponsePayload {
|
func (c *SpotifyMetadataClient) formatPlaylistData(raw *apiPlaylistResponse, callback MetadataCallback) PlaylistResponsePayload {
|
||||||
var info PlaylistInfoMetadata
|
var info PlaylistInfoMetadata
|
||||||
info.Tracks.Total = raw.Count
|
info.Tracks.Total = raw.Count
|
||||||
info.Followers.Total = raw.Followers
|
info.Followers.Total = raw.Followers
|
||||||
@@ -972,6 +1017,13 @@ func (c *SpotifyMetadataClient) formatPlaylistData(raw *apiPlaylistResponse) Pla
|
|||||||
info.Cover = raw.Cover
|
info.Cover = raw.Cover
|
||||||
info.Description = raw.Description
|
info.Description = raw.Description
|
||||||
|
|
||||||
|
if callback != nil {
|
||||||
|
callback(PlaylistResponsePayload{
|
||||||
|
PlaylistInfo: info,
|
||||||
|
TrackList: []AlbumTrackMetadata{},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
tracks := make([]AlbumTrackMetadata, 0, len(raw.Tracks))
|
tracks := make([]AlbumTrackMetadata, 0, len(raw.Tracks))
|
||||||
for _, item := range raw.Tracks {
|
for _, item := range raw.Tracks {
|
||||||
durationMS := parseDuration(item.Duration)
|
durationMS := parseDuration(item.Duration)
|
||||||
@@ -1005,7 +1057,6 @@ func (c *SpotifyMetadataClient) formatPlaylistData(raw *apiPlaylistResponse) Pla
|
|||||||
DiscNumber: item.DiscNumber,
|
DiscNumber: item.DiscNumber,
|
||||||
TotalDiscs: 0,
|
TotalDiscs: 0,
|
||||||
ExternalURL: fmt.Sprintf("https://open.spotify.com/track/%s", item.ID),
|
ExternalURL: fmt.Sprintf("https://open.spotify.com/track/%s", item.ID),
|
||||||
ISRC: item.ID,
|
|
||||||
AlbumID: item.AlbumID,
|
AlbumID: item.AlbumID,
|
||||||
AlbumURL: fmt.Sprintf("https://open.spotify.com/album/%s", item.AlbumID),
|
AlbumURL: fmt.Sprintf("https://open.spotify.com/album/%s", item.AlbumID),
|
||||||
ArtistID: artistID,
|
ArtistID: artistID,
|
||||||
@@ -1017,13 +1068,17 @@ func (c *SpotifyMetadataClient) formatPlaylistData(raw *apiPlaylistResponse) Pla
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if callback != nil {
|
||||||
|
callback(tracks)
|
||||||
|
}
|
||||||
|
|
||||||
return PlaylistResponsePayload{
|
return PlaylistResponsePayload{
|
||||||
PlaylistInfo: info,
|
PlaylistInfo: info,
|
||||||
TrackList: tracks,
|
TrackList: tracks,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *SpotifyMetadataClient) formatArtistDiscographyData(ctx context.Context, raw *apiArtistResponse) (*ArtistDiscographyPayload, error) {
|
func (c *SpotifyMetadataClient) formatArtistDiscographyData(ctx context.Context, raw *apiArtistResponse, callback MetadataCallback) (*ArtistDiscographyPayload, error) {
|
||||||
discType := "all"
|
discType := "all"
|
||||||
|
|
||||||
info := ArtistInfoMetadata{
|
info := ArtistInfoMetadata{
|
||||||
@@ -1069,7 +1124,17 @@ func (c *SpotifyMetadataClient) formatArtistDiscographyData(ctx context.Context,
|
|||||||
Images: alb.Cover,
|
Images: alb.Cover,
|
||||||
ExternalURL: fmt.Sprintf("https://open.spotify.com/album/%s", alb.ID),
|
ExternalURL: fmt.Sprintf("https://open.spotify.com/album/%s", alb.ID),
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if callback != nil {
|
||||||
|
callback(ArtistDiscographyPayload{
|
||||||
|
ArtistInfo: info,
|
||||||
|
AlbumList: albumList,
|
||||||
|
TrackList: []AlbumTrackMetadata{},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, alb := range raw.Discography.All {
|
||||||
go func(albumID string, albumName string) {
|
go func(albumID string, albumName string) {
|
||||||
sem <- struct{}{}
|
sem <- struct{}{}
|
||||||
|
|
||||||
@@ -1083,7 +1148,7 @@ func (c *SpotifyMetadataClient) formatArtistDiscographyData(ctx context.Context,
|
|||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
|
|
||||||
albumData, err := c.fetchAlbumWithClient(ctx, sharedClient, albumID)
|
albumData, err := c.fetchAlbumWithClient(ctx, sharedClient, albumID, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Error getting tracks for album %s: %v\n", albumName, err)
|
fmt.Printf("Error getting tracks for album %s: %v\n", albumName, err)
|
||||||
resultsChan <- fetchResult{tracks: []AlbumTrackMetadata{}}
|
resultsChan <- fetchResult{tracks: []AlbumTrackMetadata{}}
|
||||||
@@ -1124,7 +1189,6 @@ func (c *SpotifyMetadataClient) formatArtistDiscographyData(ctx context.Context,
|
|||||||
TotalTracks: albumData.Count,
|
TotalTracks: albumData.Count,
|
||||||
DiscNumber: tr.DiscNumber,
|
DiscNumber: tr.DiscNumber,
|
||||||
ExternalURL: fmt.Sprintf("https://open.spotify.com/track/%s", tr.ID),
|
ExternalURL: fmt.Sprintf("https://open.spotify.com/track/%s", tr.ID),
|
||||||
ISRC: tr.ID,
|
|
||||||
AlbumID: albumID,
|
AlbumID: albumID,
|
||||||
AlbumURL: fmt.Sprintf("https://open.spotify.com/album/%s", albumID),
|
AlbumURL: fmt.Sprintf("https://open.spotify.com/album/%s", albumID),
|
||||||
ArtistID: artistID,
|
ArtistID: artistID,
|
||||||
@@ -1134,6 +1198,9 @@ func (c *SpotifyMetadataClient) formatArtistDiscographyData(ctx context.Context,
|
|||||||
IsExplicit: tr.IsExplicit,
|
IsExplicit: tr.IsExplicit,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
if callback != nil {
|
||||||
|
callback(tracks)
|
||||||
|
}
|
||||||
resultsChan <- fetchResult{tracks: tracks}
|
resultsChan <- fetchResult{tracks: tracks}
|
||||||
}(alb.ID, alb.Name)
|
}(alb.ID, alb.Name)
|
||||||
}
|
}
|
||||||
@@ -1293,7 +1360,7 @@ func (c *SpotifyMetadataClient) Search(ctx context.Context, query string, limit
|
|||||||
return nil, fmt.Errorf("failed to query search: %w", err)
|
return nil, fmt.Errorf("failed to query search: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
filteredData := FilterSearch(data)
|
filteredData := FilterSearch(data, c.Separator)
|
||||||
|
|
||||||
jsonData, err := json.Marshal(filteredData)
|
jsonData, err := json.Marshal(filteredData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1410,7 +1477,7 @@ func (c *SpotifyMetadataClient) SearchByType(ctx context.Context, query string,
|
|||||||
return nil, fmt.Errorf("failed to query search: %w", err)
|
return nil, fmt.Errorf("failed to query search: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
filteredData := FilterSearch(data)
|
filteredData := FilterSearch(data, c.Separator)
|
||||||
|
|
||||||
jsonData, err := json.Marshal(filteredData)
|
jsonData, err := json.Marshal(filteredData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
//go:build !windows
|
|
||||||
|
|
||||||
package backend
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os/exec"
|
|
||||||
"runtime"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
func GetOSInfo() (string, error) {
|
|
||||||
osType := runtime.GOOS
|
|
||||||
arch := runtime.GOARCH
|
|
||||||
|
|
||||||
switch osType {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
package backend
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os/exec"
|
|
||||||
"runtime"
|
|
||||||
"strings"
|
|
||||||
"syscall"
|
|
||||||
)
|
|
||||||
|
|
||||||
func GetOSInfo() (string, error) {
|
|
||||||
arch := runtime.GOARCH
|
|
||||||
|
|
||||||
cmd := exec.Command("wmic", "os", "get", "Caption,Version", "/value")
|
|
||||||
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
|
|
||||||
out, err := cmd.Output()
|
|
||||||
if err != nil {
|
|
||||||
cmdVer := exec.Command("cmd", "/c", "ver")
|
|
||||||
cmdVer.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
|
|
||||||
outVer, errVer := cmdVer.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
|
|
||||||
}
|
|
||||||
+122
-46
@@ -8,7 +8,6 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -79,57 +78,29 @@ func NewTidalDownloader(apiURL string) *TidalDownloader {
|
|||||||
|
|
||||||
func (t *TidalDownloader) GetAvailableAPIs() ([]string, error) {
|
func (t *TidalDownloader) GetAvailableAPIs() ([]string, error) {
|
||||||
apis := []string{
|
apis := []string{
|
||||||
"https://triton.squid.wtf",
|
|
||||||
"https://hifi-one.spotisaver.net",
|
"https://hifi-one.spotisaver.net",
|
||||||
"https://hifi-two.spotisaver.net",
|
"https://hifi-two.spotisaver.net",
|
||||||
|
"https://eu-central.monochrome.tf",
|
||||||
|
"https://us-west.monochrome.tf",
|
||||||
|
"https://api.monochrome.tf",
|
||||||
|
"https://monochrome-api.samidy.com",
|
||||||
"https://tidal.kinoplus.online",
|
"https://tidal.kinoplus.online",
|
||||||
"https://tidal-api.binimum.org",
|
|
||||||
}
|
}
|
||||||
return apis, nil
|
return apis, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *TidalDownloader) GetTidalURLFromSpotify(spotifyTrackID string) (string, error) {
|
func (t *TidalDownloader) GetTidalURLFromSpotify(spotifyTrackID string) (string, error) {
|
||||||
|
|
||||||
spotifyBase := "https://open.spotify.com/track/"
|
|
||||||
spotifyURL := fmt.Sprintf("%s%s", spotifyBase, spotifyTrackID)
|
|
||||||
|
|
||||||
apiBase := "https://api.song.link/v1-alpha.1/links?url="
|
|
||||||
apiURL := fmt.Sprintf("%s%s", apiBase, url.QueryEscape(spotifyURL))
|
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", apiURL, nil)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to create request: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36")
|
|
||||||
|
|
||||||
fmt.Println("Getting Tidal URL...")
|
fmt.Println("Getting Tidal URL...")
|
||||||
|
client := NewSongLinkClient()
|
||||||
resp, err := t.client.Do(req)
|
urls, err := client.GetAllURLsFromSpotify(spotifyTrackID, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to get Tidal URL: %w", err)
|
return "", fmt.Errorf("failed to get Tidal URL: %w", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
tidalURL := urls.TidalURL
|
||||||
return "", fmt.Errorf("API returned status %d", resp.StatusCode)
|
if tidalURL == "" {
|
||||||
}
|
|
||||||
|
|
||||||
var songLinkResp struct {
|
|
||||||
LinksByPlatform map[string]struct {
|
|
||||||
URL string `json:"url"`
|
|
||||||
} `json:"linksByPlatform"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&songLinkResp); err != nil {
|
|
||||||
return "", fmt.Errorf("failed to decode response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]
|
|
||||||
if !ok || tidalLink.URL == "" {
|
|
||||||
return "", fmt.Errorf("tidal link not found")
|
return "", fmt.Errorf("tidal link not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
tidalURL := tidalLink.URL
|
|
||||||
fmt.Printf("Found Tidal URL: %s\n", tidalURL)
|
fmt.Printf("Found Tidal URL: %s\n", tidalURL)
|
||||||
return tidalURL, nil
|
return tidalURL, nil
|
||||||
}
|
}
|
||||||
@@ -165,7 +136,7 @@ func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (string,
|
|||||||
return "", fmt.Errorf("failed to create request: %w", err)
|
return "", fmt.Errorf("failed to create request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36")
|
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36")
|
||||||
|
|
||||||
resp, err := t.client.Do(req)
|
resp, err := t.client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -229,7 +200,7 @@ func (t *TidalDownloader) DownloadFile(url, filepath string) error {
|
|||||||
return fmt.Errorf("failed to create request: %w", err)
|
return fmt.Errorf("failed to create request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36")
|
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36")
|
||||||
|
|
||||||
resp, err := t.client.Do(req)
|
resp, err := t.client.Do(req)
|
||||||
|
|
||||||
@@ -275,7 +246,7 @@ func (t *TidalDownloader) DownloadFromManifest(manifestB64, outputPath string) e
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36")
|
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36")
|
||||||
return client.Do(req)
|
return client.Do(req)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -446,7 +417,7 @@ func (t *TidalDownloader) DownloadFromManifest(manifestB64, outputPath string) e
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, allowFallback bool) (string, error) {
|
func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) {
|
||||||
if outputDir != "." {
|
if outputDir != "." {
|
||||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||||
return "", fmt.Errorf("directory error: %w", err)
|
return "", fmt.Errorf("directory error: %w", err)
|
||||||
@@ -469,9 +440,15 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo
|
|||||||
albumTitle := spotifyAlbumName
|
albumTitle := spotifyAlbumName
|
||||||
|
|
||||||
artistNameForFile := sanitizeFilename(artistName)
|
artistNameForFile := sanitizeFilename(artistName)
|
||||||
|
albumArtistForFile := sanitizeFilename(spotifyAlbumArtist)
|
||||||
|
|
||||||
|
if useFirstArtistOnly {
|
||||||
|
artistNameForFile = sanitizeFilename(GetFirstArtist(artistName))
|
||||||
|
albumArtistForFile = sanitizeFilename(GetFirstArtist(spotifyAlbumArtist))
|
||||||
|
}
|
||||||
|
|
||||||
trackTitleForFile := sanitizeFilename(trackTitle)
|
trackTitleForFile := sanitizeFilename(trackTitle)
|
||||||
albumTitleForFile := sanitizeFilename(albumTitle)
|
albumTitleForFile := sanitizeFilename(albumTitle)
|
||||||
albumArtistForFile := sanitizeFilename(spotifyAlbumArtist)
|
|
||||||
|
|
||||||
filename := buildTidalFilename(trackTitleForFile, artistNameForFile, albumTitleForFile, albumArtistForFile, spotifyReleaseDate, spotifyTrackNumber, spotifyDiscNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber)
|
filename := buildTidalFilename(trackTitleForFile, artistNameForFile, albumTitleForFile, albumArtistForFile, spotifyReleaseDate, spotifyTrackNumber, spotifyDiscNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber)
|
||||||
outputFilename := filepath.Join(outputDir, filename)
|
outputFilename := filepath.Join(outputDir, filename)
|
||||||
@@ -494,11 +471,55 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type mbResult struct {
|
||||||
|
ISRC string
|
||||||
|
Metadata Metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
metaChan := make(chan mbResult, 1)
|
||||||
|
if embedGenre && spotifyURL != "" {
|
||||||
|
go func() {
|
||||||
|
res := mbResult{}
|
||||||
|
var isrc string
|
||||||
|
parts := strings.Split(spotifyURL, "/")
|
||||||
|
if len(parts) > 0 {
|
||||||
|
sID := strings.Split(parts[len(parts)-1], "?")[0]
|
||||||
|
if sID != "" {
|
||||||
|
client := NewSongLinkClient()
|
||||||
|
if val, err := client.GetISRC(sID); err == nil {
|
||||||
|
isrc = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res.ISRC = isrc
|
||||||
|
if isrc != "" {
|
||||||
|
fmt.Println("Fetching MusicBrainz metadata...")
|
||||||
|
if fetchedMeta, err := FetchMusicBrainzMetadata(isrc, trackTitle, artistName, albumTitle, useSingleGenre, embedGenre); err == nil {
|
||||||
|
res.Metadata = fetchedMeta
|
||||||
|
fmt.Println("✓ MusicBrainz metadata fetched")
|
||||||
|
} else {
|
||||||
|
fmt.Printf("Warning: Failed to fetch MusicBrainz metadata: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
metaChan <- res
|
||||||
|
}()
|
||||||
|
} else {
|
||||||
|
close(metaChan)
|
||||||
|
}
|
||||||
|
|
||||||
fmt.Printf("Downloading to: %s\n", outputFilename)
|
fmt.Printf("Downloading to: %s\n", outputFilename)
|
||||||
if err := t.DownloadFile(downloadURL, outputFilename); err != nil {
|
if err := t.DownloadFile(downloadURL, outputFilename); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var isrc string
|
||||||
|
var mbMeta Metadata
|
||||||
|
if spotifyURL != "" {
|
||||||
|
result := <-metaChan
|
||||||
|
isrc = result.ISRC
|
||||||
|
mbMeta = result.Metadata
|
||||||
|
}
|
||||||
|
|
||||||
fmt.Println("Adding metadata...")
|
fmt.Println("Adding metadata...")
|
||||||
|
|
||||||
coverPath := ""
|
coverPath := ""
|
||||||
@@ -534,6 +555,8 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo
|
|||||||
Copyright: spotifyCopyright,
|
Copyright: spotifyCopyright,
|
||||||
Publisher: spotifyPublisher,
|
Publisher: spotifyPublisher,
|
||||||
Description: "https://github.com/afkarxyz/SpotiFLAC",
|
Description: "https://github.com/afkarxyz/SpotiFLAC",
|
||||||
|
ISRC: isrc,
|
||||||
|
Genre: mbMeta.Genre,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := EmbedMetadata(outputFilename, metadata, coverPath); err != nil {
|
if err := EmbedMetadata(outputFilename, metadata, coverPath); err != nil {
|
||||||
@@ -547,7 +570,7 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo
|
|||||||
return outputFilename, nil
|
return outputFilename, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, allowFallback bool) (string, error) {
|
func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) {
|
||||||
apis, err := t.GetAvailableAPIs()
|
apis, err := t.GetAvailableAPIs()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("no APIs available for fallback: %w", err)
|
return "", fmt.Errorf("no APIs available for fallback: %w", err)
|
||||||
@@ -575,9 +598,15 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality
|
|||||||
albumTitle := spotifyAlbumName
|
albumTitle := spotifyAlbumName
|
||||||
|
|
||||||
artistNameForFile := sanitizeFilename(artistName)
|
artistNameForFile := sanitizeFilename(artistName)
|
||||||
|
albumArtistForFile := sanitizeFilename(spotifyAlbumArtist)
|
||||||
|
|
||||||
|
if useFirstArtistOnly {
|
||||||
|
artistNameForFile = sanitizeFilename(GetFirstArtist(artistName))
|
||||||
|
albumArtistForFile = sanitizeFilename(GetFirstArtist(spotifyAlbumArtist))
|
||||||
|
}
|
||||||
|
|
||||||
trackTitleForFile := sanitizeFilename(trackTitle)
|
trackTitleForFile := sanitizeFilename(trackTitle)
|
||||||
albumTitleForFile := sanitizeFilename(albumTitle)
|
albumTitleForFile := sanitizeFilename(albumTitle)
|
||||||
albumArtistForFile := sanitizeFilename(spotifyAlbumArtist)
|
|
||||||
|
|
||||||
filename := buildTidalFilename(trackTitleForFile, artistNameForFile, albumTitleForFile, albumArtistForFile, spotifyReleaseDate, spotifyTrackNumber, spotifyDiscNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber)
|
filename := buildTidalFilename(trackTitleForFile, artistNameForFile, albumTitleForFile, albumArtistForFile, spotifyReleaseDate, spotifyTrackNumber, spotifyDiscNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber)
|
||||||
outputFilename := filepath.Join(outputDir, filename)
|
outputFilename := filepath.Join(outputDir, filename)
|
||||||
@@ -600,12 +629,56 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type mbResultFallback struct {
|
||||||
|
ISRC string
|
||||||
|
Metadata Metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
metaChan := make(chan mbResultFallback, 1)
|
||||||
|
if embedGenre && spotifyURL != "" {
|
||||||
|
go func() {
|
||||||
|
res := mbResultFallback{}
|
||||||
|
var isrc string
|
||||||
|
parts := strings.Split(spotifyURL, "/")
|
||||||
|
if len(parts) > 0 {
|
||||||
|
sID := strings.Split(parts[len(parts)-1], "?")[0]
|
||||||
|
if sID != "" {
|
||||||
|
client := NewSongLinkClient()
|
||||||
|
if val, err := client.GetISRC(sID); err == nil {
|
||||||
|
isrc = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res.ISRC = isrc
|
||||||
|
if isrc != "" {
|
||||||
|
fmt.Println("Fetching MusicBrainz metadata...")
|
||||||
|
if fetchedMeta, err := FetchMusicBrainzMetadata(isrc, trackTitle, artistName, albumTitle, useSingleGenre, embedGenre); err == nil {
|
||||||
|
res.Metadata = fetchedMeta
|
||||||
|
fmt.Println("✓ MusicBrainz metadata fetched")
|
||||||
|
} else {
|
||||||
|
fmt.Printf("Warning: Failed to fetch MusicBrainz metadata: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
metaChan <- res
|
||||||
|
}()
|
||||||
|
} else {
|
||||||
|
close(metaChan)
|
||||||
|
}
|
||||||
|
|
||||||
fmt.Printf("Downloading to: %s\n", outputFilename)
|
fmt.Printf("Downloading to: %s\n", outputFilename)
|
||||||
downloader := NewTidalDownloader(successAPI)
|
downloader := NewTidalDownloader(successAPI)
|
||||||
if err := downloader.DownloadFile(downloadURL, outputFilename); err != nil {
|
if err := downloader.DownloadFile(downloadURL, outputFilename); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var isrc string
|
||||||
|
var mbMeta Metadata
|
||||||
|
if spotifyURL != "" {
|
||||||
|
result := <-metaChan
|
||||||
|
isrc = result.ISRC
|
||||||
|
mbMeta = result.Metadata
|
||||||
|
}
|
||||||
|
|
||||||
fmt.Println("Adding metadata...")
|
fmt.Println("Adding metadata...")
|
||||||
|
|
||||||
coverPath := ""
|
coverPath := ""
|
||||||
@@ -641,6 +714,8 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality
|
|||||||
Copyright: spotifyCopyright,
|
Copyright: spotifyCopyright,
|
||||||
Publisher: spotifyPublisher,
|
Publisher: spotifyPublisher,
|
||||||
Description: "https://github.com/afkarxyz/SpotiFLAC",
|
Description: "https://github.com/afkarxyz/SpotiFLAC",
|
||||||
|
ISRC: isrc,
|
||||||
|
Genre: mbMeta.Genre,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := EmbedMetadata(outputFilename, metadata, coverPath); err != nil {
|
if err := EmbedMetadata(outputFilename, metadata, coverPath); err != nil {
|
||||||
@@ -654,14 +729,14 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality
|
|||||||
return outputFilename, nil
|
return outputFilename, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *TidalDownloader) Download(spotifyTrackID, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, allowFallback bool) (string, error) {
|
func (t *TidalDownloader) Download(spotifyTrackID, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) {
|
||||||
|
|
||||||
tidalURL, err := t.GetTidalURLFromSpotify(spotifyTrackID)
|
tidalURL, err := t.GetTidalURLFromSpotify(spotifyTrackID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("songlink couldn't find Tidal URL: %w", err)
|
return "", fmt.Errorf("songlink couldn't find Tidal URL: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return t.DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyURL, allowFallback)
|
return t.DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyURL, allowFallback, useFirstArtistOnly, useSingleGenre, embedGenre)
|
||||||
}
|
}
|
||||||
|
|
||||||
type SegmentTemplate struct {
|
type SegmentTemplate struct {
|
||||||
@@ -917,6 +992,7 @@ func buildTidalFilename(title, artist, album, albumArtist, releaseDate string, t
|
|||||||
filename = strings.ReplaceAll(filename, "{album}", album)
|
filename = strings.ReplaceAll(filename, "{album}", album)
|
||||||
filename = strings.ReplaceAll(filename, "{album_artist}", albumArtist)
|
filename = strings.ReplaceAll(filename, "{album_artist}", albumArtist)
|
||||||
filename = strings.ReplaceAll(filename, "{year}", year)
|
filename = strings.ReplaceAll(filename, "{year}", year)
|
||||||
|
filename = strings.ReplaceAll(filename, "{date}", SanitizeFilename(releaseDate))
|
||||||
|
|
||||||
if discNumber > 0 {
|
if discNumber > 0 {
|
||||||
filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber))
|
filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber))
|
||||||
|
|||||||
@@ -1,217 +0,0 @@
|
|||||||
package backend
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"mime/multipart"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type SendNowResponse []struct {
|
|
||||||
FileCode string `json:"file_code"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func UploadToSendNow(filePath string) (string, error) {
|
|
||||||
file, err := os.Open(filePath)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to open file: %v", err)
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
return uploadToService(filepath.Base(filePath), file)
|
|
||||||
}
|
|
||||||
|
|
||||||
func UploadBytesToSendNow(filename string, data []byte) (string, error) {
|
|
||||||
return uploadToService(filename, bytes.NewReader(data))
|
|
||||||
}
|
|
||||||
|
|
||||||
func uploadToService(filename string, fileReader io.Reader) (string, error) {
|
|
||||||
body := &bytes.Buffer{}
|
|
||||||
writer := multipart.NewWriter(body)
|
|
||||||
|
|
||||||
fields := map[string]string{
|
|
||||||
"sess_id": "",
|
|
||||||
"utype": "anon",
|
|
||||||
"hidden": "",
|
|
||||||
"enableemail": "",
|
|
||||||
"link_rcpt": "",
|
|
||||||
"link_pass": "",
|
|
||||||
"file_expire_time": "",
|
|
||||||
"file_expire_unit": "DAY",
|
|
||||||
"file_max_dl": "1",
|
|
||||||
"file_public": "1",
|
|
||||||
"keepalive": "1",
|
|
||||||
}
|
|
||||||
|
|
||||||
for key, val := range fields {
|
|
||||||
if err := writer.WriteField(key, val); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
part, err := writer.CreateFormFile("file_0", filename)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if _, err := io.Copy(part, fileReader); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
writer.Close()
|
|
||||||
|
|
||||||
uploadURL, err := getUploadURL()
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to get upload server: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequest("POST", uploadURL, body)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36")
|
|
||||||
req.Header.Set("Origin", "https://send.now")
|
|
||||||
req.Header.Set("Referer", "https://send.now/")
|
|
||||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
|
||||||
|
|
||||||
client := &http.Client{Timeout: 60 * time.Second}
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("upload failed: %v", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
respBytes, _ := io.ReadAll(resp.Body)
|
|
||||||
return "", fmt.Errorf("server error %d: %s", resp.StatusCode, string(respBytes))
|
|
||||||
}
|
|
||||||
|
|
||||||
respBytes, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
var result SendNowResponse
|
|
||||||
if err := json.Unmarshal(respBytes, &result); err != nil {
|
|
||||||
return "", fmt.Errorf("failed to parse response: %v, raw: %s", err, string(respBytes))
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(result) == 0 || result[0].FileCode == "" {
|
|
||||||
return "", fmt.Errorf("invalid response format")
|
|
||||||
}
|
|
||||||
|
|
||||||
fileCode := result[0].FileCode
|
|
||||||
downloadLink := fmt.Sprintf("https://send.now/%s", fileCode)
|
|
||||||
|
|
||||||
ext := strings.ToLower(filepath.Ext(filename))
|
|
||||||
if ext == ".mp4" || ext == ".mov" || ext == ".mkv" || ext == ".webm" || ext == ".avi" {
|
|
||||||
return fmt.Sprintf("[Video](%s)", downloadLink), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return fetchDirectImageLink(downloadLink)
|
|
||||||
}
|
|
||||||
|
|
||||||
func getUploadURL() (string, error) {
|
|
||||||
req, err := http.NewRequest("GET", "https://send.now/", nil)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36")
|
|
||||||
|
|
||||||
client := &http.Client{Timeout: 30 * time.Second}
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
return "", fmt.Errorf("failed to fetch main page: status %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
bodyBytes, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
body := string(bodyBytes)
|
|
||||||
|
|
||||||
re := regexp.MustCompile(`action=["'](https://[^"']+/cgi-bin/upload\.cgi\?upload_type=file[^"']*)["']`)
|
|
||||||
matches := re.FindStringSubmatch(body)
|
|
||||||
if len(matches) > 1 {
|
|
||||||
return matches[1], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
reFallback := regexp.MustCompile(`action=["'](https://[^"']+/cgi-bin/upload\.cgi)`)
|
|
||||||
matchesFallback := reFallback.FindStringSubmatch(body)
|
|
||||||
if len(matchesFallback) > 1 {
|
|
||||||
return matchesFallback[1] + "?upload_type=file&utype=anon", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return "", fmt.Errorf("upload URL not found in main page")
|
|
||||||
}
|
|
||||||
|
|
||||||
func fetchDirectImageLink(url string) (string, error) {
|
|
||||||
req, err := http.NewRequest("GET", url, nil)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36")
|
|
||||||
|
|
||||||
client := &http.Client{Timeout: 30 * time.Second}
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
htmlBytes, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
htmlStr := string(htmlBytes)
|
|
||||||
|
|
||||||
reFullRes := regexp.MustCompile(`(?i)<a[^>]+href=["']([^"']+)["'][^>]*title=["']Open image on new tab["']`)
|
|
||||||
matchesFull := reFullRes.FindStringSubmatch(htmlStr)
|
|
||||||
if len(matchesFull) > 1 {
|
|
||||||
return fmt.Sprintf("", matchesFull[1]), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
reClipboard := regexp.MustCompile(`(?s)data-clipboard-text=['"]<a href="[^"]+".*?><img src="([^"]+)"`)
|
|
||||||
matches := reClipboard.FindStringSubmatch(htmlStr)
|
|
||||||
if len(matches) > 1 {
|
|
||||||
return fmt.Sprintf("", matches[1]), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
reImg := regexp.MustCompile(`(?i)<img[^>]+src=["']([^"']*?\.send\.now/i/[^"']+)["']`)
|
|
||||||
matchesImg := reImg.FindStringSubmatch(htmlStr)
|
|
||||||
if len(matchesImg) > 1 {
|
|
||||||
return fmt.Sprintf("", matchesImg[1]), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
reAnchor := regexp.MustCompile(`(?i)<a[^>]+href=["']([^"']+\.(?:jpg|jpeg|png|gif|webp))["']`)
|
|
||||||
matchesAnchor := reAnchor.FindStringSubmatch(htmlStr)
|
|
||||||
if len(matchesAnchor) > 1 {
|
|
||||||
return fmt.Sprintf("", matchesAnchor[1]), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
reGeneric := regexp.MustCompile(`(?i)<img[^>]+src=["']([^"']+\.(?:jpg|jpeg|png|gif|webp))["']`)
|
|
||||||
matchesGeneric := reGeneric.FindAllStringSubmatch(htmlStr, -1)
|
|
||||||
for _, match := range matchesGeneric {
|
|
||||||
if len(match) > 1 {
|
|
||||||
link := match[1]
|
|
||||||
|
|
||||||
if !regexp.MustCompile(`(?i)(logo|icon|button|assets)`).MatchString(filepath.Base(link)) {
|
|
||||||
return fmt.Sprintf("", link), nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Sprintf("[View File](%s)", url), nil
|
|
||||||
}
|
|
||||||
+16
-15
@@ -26,32 +26,33 @@
|
|||||||
"@radix-ui/react-toggle": "^1.1.10",
|
"@radix-ui/react-toggle": "^1.1.10",
|
||||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.2.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.575.0",
|
||||||
"motion": "^12.26.2",
|
"motion": "^12.34.3",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "^19.2.3",
|
"radix-ui": "^1.4.3",
|
||||||
"react-dom": "^19.2.3",
|
"react": "^19.2.4",
|
||||||
|
"react-dom": "^19.2.4",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tailwindcss": "^4.1.18"
|
"tailwindcss": "^4.2.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.2",
|
"@eslint/js": "^10.0.1",
|
||||||
"@types/node": "^25.0.8",
|
"@types/node": "^25.3.0",
|
||||||
"@types/react": "^19.2.8",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^5.1.2",
|
"@vitejs/plugin-react": "^5.1.4",
|
||||||
"eslint": "^9.39.2",
|
"eslint": "^10.0.2",
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.4.26",
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
"globals": "^17.0.0",
|
"globals": "^17.3.0",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"typescript-eslint": "^8.53.0",
|
"typescript-eslint": "^8.56.1",
|
||||||
"vite": "^7.3.1"
|
"vite": "^7.3.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1 +1 @@
|
|||||||
9fee02ec6592ede9ade4b36d56bd4d6d
|
867c45db7982e126a7249d80210f23be
|
||||||
Generated
+1786
-1245
File diff suppressed because it is too large
Load Diff
+147
-22
@@ -3,9 +3,9 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog";
|
||||||
import { Search, X, ArrowUp } from "lucide-react";
|
import { Search, X, ArrowUp } from "lucide-react";
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
import { getSettings, getSettingsWithDefaults, loadSettings, saveSettings, applyThemeMode, applyFont } from "@/lib/settings";
|
import { getSettings, getSettingsWithDefaults, loadSettings, saveSettings, applyThemeMode, applyFont, updateSettings } from "@/lib/settings";
|
||||||
import { applyTheme } from "@/lib/themes";
|
import { applyTheme } from "@/lib/themes";
|
||||||
import { OpenFolder, CheckFFmpegInstalled, DownloadFFmpeg } from "../wailsjs/go/main/App";
|
import { OpenFolder, CheckFFmpegInstalled, DownloadFFmpeg, GetBrewPath, InstallFFmpegWithBrew } from "../wailsjs/go/main/App";
|
||||||
import { EventsOn, EventsOff, Quit } from "../wailsjs/runtime/runtime";
|
import { EventsOn, EventsOff, Quit } from "../wailsjs/runtime/runtime";
|
||||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||||
import { TitleBar } from "@/components/TitleBar";
|
import { TitleBar } from "@/components/TitleBar";
|
||||||
@@ -20,6 +20,7 @@ import { DownloadQueue } from "@/components/DownloadQueue";
|
|||||||
import { DownloadProgressToast } from "@/components/DownloadProgressToast";
|
import { DownloadProgressToast } from "@/components/DownloadProgressToast";
|
||||||
import { AudioAnalysisPage } from "@/components/AudioAnalysisPage";
|
import { AudioAnalysisPage } from "@/components/AudioAnalysisPage";
|
||||||
import { AudioConverterPage } from "@/components/AudioConverterPage";
|
import { AudioConverterPage } from "@/components/AudioConverterPage";
|
||||||
|
import { AudioResamplerPage } from "@/components/AudioResamplerPage";
|
||||||
import { FileManagerPage } from "@/components/FileManagerPage";
|
import { FileManagerPage } from "@/components/FileManagerPage";
|
||||||
import { SettingsPage } from "@/components/SettingsPage";
|
import { SettingsPage } from "@/components/SettingsPage";
|
||||||
import { DebugLoggerPage } from "@/components/DebugLoggerPage";
|
import { DebugLoggerPage } from "@/components/DebugLoggerPage";
|
||||||
@@ -35,6 +36,72 @@ import { useDownloadQueueDialog } from "@/hooks/useDownloadQueueDialog";
|
|||||||
import { useDownloadProgress } from "@/hooks/useDownloadProgress";
|
import { useDownloadProgress } from "@/hooks/useDownloadProgress";
|
||||||
const HISTORY_KEY = "spotiflac_fetch_history";
|
const HISTORY_KEY = "spotiflac_fetch_history";
|
||||||
const MAX_HISTORY = 5;
|
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() {
|
function App() {
|
||||||
const [currentPage, setCurrentPage] = useState<PageType>("main");
|
const [currentPage, setCurrentPage] = useState<PageType>("main");
|
||||||
const [spotifyUrl, setSpotifyUrl] = useState("");
|
const [spotifyUrl, setSpotifyUrl] = useState("");
|
||||||
@@ -65,6 +132,7 @@ function App() {
|
|||||||
const downloadQueue = useDownloadQueueDialog();
|
const downloadQueue = useDownloadQueueDialog();
|
||||||
const downloadProgress = useDownloadProgress();
|
const downloadProgress = useDownloadProgress();
|
||||||
const [isFFmpegInstalled, setIsFFmpegInstalled] = useState<boolean | null>(null);
|
const [isFFmpegInstalled, setIsFFmpegInstalled] = useState<boolean | null>(null);
|
||||||
|
const [brewPath, setBrewPath] = useState<string>("");
|
||||||
const [isInstallingFFmpeg, setIsInstallingFFmpeg] = useState(false);
|
const [isInstallingFFmpeg, setIsInstallingFFmpeg] = useState(false);
|
||||||
const [ffmpegInstallProgress, setFfmpegInstallProgress] = useState(0);
|
const [ffmpegInstallProgress, setFfmpegInstallProgress] = useState(0);
|
||||||
const [ffmpegInstallStatus, setFfmpegInstallStatus] = useState("");
|
const [ffmpegInstallStatus, setFfmpegInstallStatus] = useState("");
|
||||||
@@ -92,6 +160,8 @@ function App() {
|
|||||||
try {
|
try {
|
||||||
const installed = await CheckFFmpegInstalled();
|
const installed = await CheckFFmpegInstalled();
|
||||||
setIsFFmpegInstalled(installed);
|
setIsFFmpegInstalled(installed);
|
||||||
|
const brew = await GetBrewPath();
|
||||||
|
setBrewPath(brew);
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
console.error("Failed to check FFmpeg:", err);
|
console.error("Failed to check FFmpeg:", err);
|
||||||
@@ -119,6 +189,17 @@ function App() {
|
|||||||
window.removeEventListener("scroll", handleScroll);
|
window.removeEventListener("scroll", handleScroll);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
const handleEnableSpotFetchApi = async () => {
|
||||||
|
try {
|
||||||
|
await updateSettings({ useSpotFetchAPI: true });
|
||||||
|
metadata.setShowApiModal(false);
|
||||||
|
toast.success("SpotFetch API enabled! You can now try fetching again.");
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.error("Failed to enable SpotFetch API:", err);
|
||||||
|
toast.error("Failed to update settings");
|
||||||
|
}
|
||||||
|
};
|
||||||
const scrollToTop = useCallback(() => {
|
const scrollToTop = useCallback(() => {
|
||||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
}, []);
|
}, []);
|
||||||
@@ -152,14 +233,16 @@ function App() {
|
|||||||
try {
|
try {
|
||||||
const saved = localStorage.getItem(HISTORY_KEY);
|
const saved = localStorage.getItem(HISTORY_KEY);
|
||||||
if (saved) {
|
if (saved) {
|
||||||
setFetchHistory(JSON.parse(saved));
|
const deduped = dedupeHistoryItems(JSON.parse(saved));
|
||||||
|
setFetchHistory(deduped);
|
||||||
|
localStorage.setItem(HISTORY_KEY, JSON.stringify(deduped));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
console.error("Failed to load history:", err);
|
console.error("Failed to load history:", err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const handleInstallFFmpeg = async () => {
|
const handleInstallFFmpeg = async (useBrew: boolean = false) => {
|
||||||
setIsInstallingFFmpeg(true);
|
setIsInstallingFFmpeg(true);
|
||||||
setFfmpegInstallProgress(0);
|
setFfmpegInstallProgress(0);
|
||||||
setFfmpegInstallStatus("starting");
|
setFfmpegInstallStatus("starting");
|
||||||
@@ -176,11 +259,11 @@ function App() {
|
|||||||
EventsOn("ffmpeg:status", (status: string) => {
|
EventsOn("ffmpeg:status", (status: string) => {
|
||||||
setFfmpegInstallStatus(status);
|
setFfmpegInstallStatus(status);
|
||||||
});
|
});
|
||||||
const response = await DownloadFFmpeg();
|
const response = useBrew ? await InstallFFmpegWithBrew() : await DownloadFFmpeg();
|
||||||
EventsOff("ffmpeg:progress");
|
EventsOff("ffmpeg:progress");
|
||||||
EventsOff("ffmpeg:status");
|
EventsOff("ffmpeg:status");
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
toast.success("FFmpeg installed successfully!");
|
toast.success(useBrew ? "FFmpeg installed successfully via Homebrew!" : "FFmpeg installed successfully!");
|
||||||
setIsFFmpegInstalled(true);
|
setIsFFmpegInstalled(true);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
@@ -207,9 +290,12 @@ function App() {
|
|||||||
};
|
};
|
||||||
const addToHistory = (item: Omit<HistoryItem, "id" | "timestamp">) => {
|
const addToHistory = (item: Omit<HistoryItem, "id" | "timestamp">) => {
|
||||||
setFetchHistory((prev) => {
|
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 = {
|
const newItem: HistoryItem = {
|
||||||
...item,
|
...item,
|
||||||
|
url: normalizedUrl,
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
};
|
};
|
||||||
@@ -290,19 +376,19 @@ function App() {
|
|||||||
setSearchQuery(value);
|
setSearchQuery(value);
|
||||||
setCurrentListPage(1);
|
setCurrentListPage(1);
|
||||||
};
|
};
|
||||||
const toggleTrackSelection = (isrc: string) => {
|
const toggleTrackSelection = (id: string) => {
|
||||||
setSelectedTracks((prev) => prev.includes(isrc) ? prev.filter((id) => id !== isrc) : [...prev, isrc]);
|
setSelectedTracks((prev) => prev.includes(id) ? prev.filter((prevId) => prevId !== id) : [...prev, id]);
|
||||||
};
|
};
|
||||||
const toggleSelectAll = (tracks: any[]) => {
|
const toggleSelectAll = (tracks: any[]) => {
|
||||||
const tracksWithIsrc = tracks.filter((track) => track.isrc).map((track) => track.isrc);
|
const tracksWithId = tracks.filter((track) => track.spotify_id).map((track) => track.spotify_id || "");
|
||||||
if (tracksWithIsrc.length === 0)
|
if (tracksWithId.length === 0)
|
||||||
return;
|
return;
|
||||||
const allSelected = tracksWithIsrc.every(isrc => selectedTracks.includes(isrc));
|
const allSelected = tracksWithId.every(id => selectedTracks.includes(id));
|
||||||
if (allSelected) {
|
if (allSelected) {
|
||||||
setSelectedTracks(prev => prev.filter(isrc => !tracksWithIsrc.includes(isrc)));
|
setSelectedTracks(prev => prev.filter(id => !tracksWithId.includes(id)));
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
setSelectedTracks(prev => Array.from(new Set([...prev, ...tracksWithIsrc])));
|
setSelectedTracks(prev => Array.from(new Set([...prev, ...tracksWithId])));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const handleOpenFolder = async () => {
|
const handleOpenFolder = async () => {
|
||||||
@@ -324,11 +410,14 @@ function App() {
|
|||||||
return null;
|
return null;
|
||||||
if ("track" in metadata.metadata) {
|
if ("track" in metadata.metadata) {
|
||||||
const { track } = metadata.metadata;
|
const { track } = metadata.metadata;
|
||||||
return (<TrackInfo track={track} isDownloading={download.isDownloading} downloadingTrack={download.downloadingTrack} isDownloaded={download.downloadedTracks.has(track.isrc)} isFailed={download.failedTracks.has(track.isrc)} isSkipped={download.skippedTracks.has(track.isrc)} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} downloadedLyrics={lyrics.downloadedLyrics.has(track.spotify_id || "")} failedLyrics={lyrics.failedLyrics.has(track.spotify_id || "")} skippedLyrics={lyrics.skippedLyrics.has(track.spotify_id || "")} checkingAvailability={availability.checkingTrackId === track.spotify_id} availability={availability.availabilityMap.get(track.spotify_id || "")} downloadingCover={cover.downloadingCoverTrack === (track.spotify_id || `${track.name}-${track.artists}`)} downloadedCover={cover.downloadedCovers.has(track.spotify_id || `${track.name}-${track.artists}`)} failedCover={cover.failedCovers.has(track.spotify_id || `${track.name}-${track.artists}`)} skippedCover={cover.skippedCovers.has(track.spotify_id || `${track.name}-${track.artists}`)} onDownload={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, albumArtist, releaseDate, discNumber) => 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} onBack={metadata.resetMetadata}/>);
|
const trackId = track.spotify_id || "";
|
||||||
|
return (<TrackInfo track={track} isDownloading={download.isDownloading} downloadingTrack={download.downloadingTrack} isDownloaded={download.downloadedTracks.has(trackId)} isFailed={download.failedTracks.has(trackId)} isSkipped={download.skippedTracks.has(trackId)} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} downloadedLyrics={lyrics.downloadedLyrics.has(track.spotify_id || "")} failedLyrics={lyrics.failedLyrics.has(track.spotify_id || "")} skippedLyrics={lyrics.skippedLyrics.has(track.spotify_id || "")} checkingAvailability={availability.checkingTrackId === track.spotify_id} availability={availability.availabilityMap.get(track.spotify_id || "")} downloadingCover={cover.downloadingCoverTrack === (track.spotify_id || `${track.name}-${track.artists}`)} downloadedCover={cover.downloadedCovers.has(track.spotify_id || `${track.name}-${track.artists}`)} failedCover={cover.failedCovers.has(track.spotify_id || `${track.name}-${track.artists}`)} skippedCover={cover.skippedCovers.has(track.spotify_id || `${track.name}-${track.artists}`)} onDownload={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, undefined, undefined, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _playlistName, _position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, undefined, undefined, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onOpenFolder={handleOpenFolder} onBack={metadata.resetMetadata}/>);
|
||||||
}
|
}
|
||||||
if ("album_info" in metadata.metadata) {
|
if ("album_info" in metadata.metadata) {
|
||||||
const { album_info, track_list } = 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) => {
|
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);
|
const artistUrl = await metadata.handleArtistClick(artist);
|
||||||
if (artistUrl) {
|
if (artistUrl) {
|
||||||
setSpotifyUrl(artistUrl);
|
setSpotifyUrl(artistUrl);
|
||||||
@@ -343,6 +432,8 @@ function App() {
|
|||||||
if ("playlist_info" in metadata.metadata) {
|
if ("playlist_info" in metadata.metadata) {
|
||||||
const { playlist_info, track_list } = 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) => {
|
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);
|
const artistUrl = await metadata.handleArtistClick(artist);
|
||||||
if (artistUrl) {
|
if (artistUrl) {
|
||||||
setSpotifyUrl(artistUrl);
|
setSpotifyUrl(artistUrl);
|
||||||
@@ -357,6 +448,8 @@ function App() {
|
|||||||
if ("artist_info" in metadata.metadata) {
|
if ("artist_info" in metadata.metadata) {
|
||||||
const { artist_info, album_list, track_list } = 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) => {
|
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);
|
const artistUrl = await metadata.handleArtistClick(artist);
|
||||||
if (artistUrl) {
|
if (artistUrl) {
|
||||||
setSpotifyUrl(artistUrl);
|
setSpotifyUrl(artistUrl);
|
||||||
@@ -403,7 +496,7 @@ function App() {
|
|||||||
case "debug":
|
case "debug":
|
||||||
return <DebugLoggerPage />;
|
return <DebugLoggerPage />;
|
||||||
case "about":
|
case "about":
|
||||||
return <AboutPage version={CURRENT_VERSION}/>;
|
return <AboutPage />;
|
||||||
case "history":
|
case "history":
|
||||||
return <HistoryPage onHistorySelect={(cachedData) => {
|
return <HistoryPage onHistorySelect={(cachedData) => {
|
||||||
metadata.loadFromCache(cachedData);
|
metadata.loadFromCache(cachedData);
|
||||||
@@ -413,6 +506,8 @@ function App() {
|
|||||||
return <AudioAnalysisPage />;
|
return <AudioAnalysisPage />;
|
||||||
case "audio-converter":
|
case "audio-converter":
|
||||||
return <AudioConverterPage />;
|
return <AudioConverterPage />;
|
||||||
|
case "audio-resampler":
|
||||||
|
return <AudioResamplerPage />;
|
||||||
case "file-manager":
|
case "file-manager":
|
||||||
return <FileManagerPage />;
|
return <FileManagerPage />;
|
||||||
default:
|
default:
|
||||||
@@ -441,6 +536,10 @@ function App() {
|
|||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={async () => {
|
<Button onClick={async () => {
|
||||||
|
const pendingAlbumUrl = metadata.selectedAlbum?.external_urls;
|
||||||
|
if (pendingAlbumUrl) {
|
||||||
|
setSpotifyUrl(pendingAlbumUrl);
|
||||||
|
}
|
||||||
const albumUrl = await metadata.handleConfirmAlbumFetch();
|
const albumUrl = await metadata.handleConfirmAlbumFetch();
|
||||||
if (albumUrl) {
|
if (albumUrl) {
|
||||||
setSpotifyUrl(albumUrl);
|
setSpotifyUrl(albumUrl);
|
||||||
@@ -510,14 +609,19 @@ function App() {
|
|||||||
|
|
||||||
|
|
||||||
<Dialog open={isFFmpegInstalled === false} onOpenChange={() => { }}>
|
<Dialog open={isFFmpegInstalled === false} onOpenChange={() => { }}>
|
||||||
<DialogContent className="max-w-[360px] [&>button]:hidden p-6 gap-5">
|
<DialogContent className="max-w-[450px] [&>button]:hidden p-6 gap-5">
|
||||||
<DialogHeader className="space-y-2">
|
<DialogHeader className="space-y-2">
|
||||||
<DialogTitle className="text-lg font-bold tracking-tight">
|
<DialogTitle className="text-lg font-bold tracking-tight">
|
||||||
FFmpeg Required
|
FFmpeg Required
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription className="text-sm text-foreground/70 leading-relaxed font-normal">
|
<DialogDescription className="text-sm text-foreground/70 leading-relaxed font-normal">
|
||||||
FFmpeg is essential for SpotiFLAC to function properly.
|
{brewPath ? (<>
|
||||||
This setup will download about <span className="text-foreground font-semibold">100-200MB</span> of data.
|
FFmpeg is essential for SpotiFLAC to function properly.
|
||||||
|
Homebrew detected. Recommended: <span className="text-foreground font-semibold">brew install ffmpeg</span>
|
||||||
|
</>) : (<>
|
||||||
|
FFmpeg is essential for SpotiFLAC to function properly.
|
||||||
|
This setup will download about <span className="text-foreground font-semibold">100-200MB</span> of data.
|
||||||
|
</>)}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@@ -545,12 +649,33 @@ function App() {
|
|||||||
</div>)}
|
</div>)}
|
||||||
</div>)}
|
</div>)}
|
||||||
|
|
||||||
<DialogFooter className="flex-row gap-3 pt-2">
|
<DialogFooter className={`flex-row gap-3 pt-2 ${brewPath ? 'flex-col' : ''}`}>
|
||||||
{!isInstallingFFmpeg && (<Button variant="outline" className="flex-1 h-11 text-sm font-bold transition-colors" onClick={() => Quit()}>
|
{!isInstallingFFmpeg && (<Button variant="outline" className="flex-1 h-11 text-sm font-bold transition-colors" onClick={() => Quit()}>
|
||||||
Exit
|
Exit
|
||||||
</Button>)}
|
</Button>)}
|
||||||
<Button className={`${isInstallingFFmpeg ? 'w-full' : 'flex-1'} h-11 text-sm font-bold shadow-lg shadow-primary/10`} onClick={handleInstallFFmpeg} disabled={isInstallingFFmpeg}>
|
{brewPath ? (<Button className="flex-1 h-11 text-sm font-bold shadow-lg shadow-primary/10" onClick={() => handleInstallFFmpeg(true)} disabled={isInstallingFFmpeg}>
|
||||||
{isInstallingFFmpeg ? "Installing..." : "Install now"}
|
{isInstallingFFmpeg ? "Installing..." : "Install via Homebrew"}
|
||||||
|
</Button>) : (<Button className={`${isInstallingFFmpeg ? 'w-full' : 'flex-1'} h-11 text-sm font-bold shadow-lg shadow-primary/10`} onClick={() => handleInstallFFmpeg(false)} disabled={isInstallingFFmpeg}>
|
||||||
|
{isInstallingFFmpeg ? "Installing..." : "Install now"}
|
||||||
|
</Button>)}
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog open={metadata.showApiModal} onOpenChange={metadata.setShowApiModal}>
|
||||||
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>SpotFetch API Recommended</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Direct fetch failed. This usually happens when your <span className="text-foreground font-bold">country is blocked</span> by Spotify or your IP is restricted. Would you like to enable the <span className="text-foreground font-bold">SpotFetch API</span> to bypass this?
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => metadata.setShowApiModal(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleEnableSpotFetchApi}>
|
||||||
|
Enable SpotFetch API
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 8.0 KiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 8.0 KiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 8.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 903 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
@@ -1,81 +1,27 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { openExternal } from "@/lib/utils";
|
import { openExternal } from "@/lib/utils";
|
||||||
import { GetOSInfo } from "../../wailsjs/go/main/App";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@/components/ui/card";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Star, GitFork, Clock, Download, Blocks, Heart, Copy, CircleCheck, Info } from "lucide-react";
|
||||||
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, Star, GitFork, Clock, Download, CircleHelp, Blocks, Heart } from "lucide-react";
|
|
||||||
import AudioTTSProIcon from "@/assets/audiotts-pro.webp";
|
import AudioTTSProIcon from "@/assets/audiotts-pro.webp";
|
||||||
import ChatGPTTTSIcon from "@/assets/chatgpt-tts.webp";
|
import ChatGPTTTSIcon from "@/assets/chatgpt-tts.webp";
|
||||||
|
import XIcon from "@/assets/x.webp";
|
||||||
import XProIcon from "@/assets/x-pro.webp";
|
import XProIcon from "@/assets/x-pro.webp";
|
||||||
import SpotubeDLIcon from "@/assets/icons/spotubedl.svg";
|
import SpotubeDLIcon from "@/assets/icons/spotubedl.svg";
|
||||||
import SpotiDownloaderIcon from "@/assets/icons/spotidownloader.svg";
|
import SpotiDownloaderIcon from "@/assets/icons/spotidownloader.svg";
|
||||||
import XBatchDLIcon from "@/assets/icons/xbatchdl.svg";
|
import XBatchDLIcon from "@/assets/icons/xbatchdl.svg";
|
||||||
import SpotiFLACNextIcon from "@/assets/icons/next.svg";
|
import SpotiFLACNextIcon from "@/assets/icons/next.svg";
|
||||||
import BmcLogo from "@/assets/bmc-logo.svg";
|
import KofiLogo from "@/assets/ko-fi.gif";
|
||||||
import KofiLogo from "@/assets/kofi_symbol.svg";
|
import KofiSvg from "@/assets/kofi_symbol.svg";
|
||||||
|
import UsdtBarcode from "@/assets/usdt.jpg";
|
||||||
import { langColors } from "@/assets/github-lang-colors";
|
import { langColors } from "@/assets/github-lang-colors";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
export function AboutPage() {
|
||||||
import { DragDropMedia } from "./DragDropTextarea";
|
const [activeTab, setActiveTab] = useState<"projects" | "support">("projects");
|
||||||
interface AboutPageProps {
|
|
||||||
version: string;
|
|
||||||
}
|
|
||||||
export function AboutPage({ version }: AboutPageProps) {
|
|
||||||
const [os, setOs] = useState("Unknown");
|
|
||||||
const [location, setLocation] = useState("Unknown");
|
|
||||||
const [activeTab, setActiveTab] = useState<"bug_report" | "feature_request" | "faq" | "projects" | "support">("bug_report");
|
|
||||||
const [bugType, setBugType] = useState("Track");
|
|
||||||
const [problem, setProblem] = useState("");
|
|
||||||
const [spotifyUrl, setSpotifyUrl] = useState("");
|
|
||||||
const [bugContext, setBugContext] = useState("");
|
|
||||||
const [featureDesc, setFeatureDesc] = useState("");
|
|
||||||
const [useCase, setUseCase] = useState("");
|
|
||||||
const [featureContext, setFeatureContext] = useState("");
|
|
||||||
const [repoStats, setRepoStats] = useState<Record<string, any>>({});
|
const [repoStats, setRepoStats] = useState<Record<string, any>>({});
|
||||||
|
const [copiedUsdt, setCopiedUsdt] = useState(false);
|
||||||
useEffect(() => {
|
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 fetchRepoStats = async () => {
|
const fetchRepoStats = async () => {
|
||||||
const CACHE_KEY = 'github_repo_stats';
|
const CACHE_KEY = "github_repo_stats_v3";
|
||||||
const CACHE_DURATION = 1000 * 60 * 60;
|
const CACHE_DURATION = 1000 * 60 * 60;
|
||||||
const cached = localStorage.getItem(CACHE_KEY);
|
const cached = localStorage.getItem(CACHE_KEY);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
@@ -87,13 +33,13 @@ export function AboutPage({ version }: AboutPageProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
console.error('Failed to parse cache:', err);
|
console.error("Failed to parse cache:", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const repos = [
|
const repos = [
|
||||||
{ name: 'SpotiDownloader', owner: 'afkarxyz' },
|
{ name: "SpotiDownloader", owner: "afkarxyz" },
|
||||||
{ name: 'SpotiFLAC-Next', owner: 'spotiverse' },
|
{ name: "SpotiFLAC-Next", owner: "spotiverse" },
|
||||||
{ name: 'Twitter-X-Media-Batch-Downloader', owner: 'afkarxyz' }
|
{ name: "Twitter-X-Media-Batch-Downloader", owner: "afkarxyz" },
|
||||||
];
|
];
|
||||||
const stats: Record<string, any> = {};
|
const stats: Record<string, any> = {};
|
||||||
for (const repo of repos) {
|
for (const repo of repos) {
|
||||||
@@ -101,7 +47,7 @@ export function AboutPage({ version }: AboutPageProps) {
|
|||||||
const [repoRes, releasesRes, langsRes] = await Promise.all([
|
const [repoRes, releasesRes, langsRes] = await Promise.all([
|
||||||
fetch(`https://api.github.com/repos/${repo.owner}/${repo.name}`),
|
fetch(`https://api.github.com/repos/${repo.owner}/${repo.name}`),
|
||||||
fetch(`https://api.github.com/repos/${repo.owner}/${repo.name}/releases`),
|
fetch(`https://api.github.com/repos/${repo.owner}/${repo.name}/releases`),
|
||||||
fetch(`https://api.github.com/repos/${repo.owner}/${repo.name}/languages`)
|
fetch(`https://api.github.com/repos/${repo.owner}/${repo.name}/languages`),
|
||||||
]);
|
]);
|
||||||
if (repoRes.status === 403) {
|
if (repoRes.status === 403) {
|
||||||
if (cached) {
|
if (cached) {
|
||||||
@@ -110,16 +56,20 @@ export function AboutPage({ version }: AboutPageProps) {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (repoRes.ok && releasesRes.ok && langsRes.ok) {
|
if (repoRes.ok) {
|
||||||
const repoData = await repoRes.json();
|
const repoData = await repoRes.json();
|
||||||
const releases = await releasesRes.json();
|
const releases = releasesRes.ok ? await releasesRes.json() : [];
|
||||||
const languages = await langsRes.json();
|
const languages = langsRes.ok ? await langsRes.json() : {};
|
||||||
let totalDownloads = 0;
|
let totalDownloads = 0;
|
||||||
let latestDownloads = 0;
|
let latestDownloads = 0;
|
||||||
|
let latestVersion = "";
|
||||||
if (releases.length > 0) {
|
if (releases.length > 0) {
|
||||||
latestDownloads = releases[0].assets?.reduce((sum: number, asset: any) => sum + (asset.download_count || 0), 0) || 0;
|
latestVersion = releases[0].tag_name || "";
|
||||||
|
latestDownloads =
|
||||||
|
releases[0].assets?.reduce((sum: number, asset: any) => sum + (asset.download_count || 0), 0) || 0;
|
||||||
totalDownloads = releases.reduce((sum: number, release: any) => {
|
totalDownloads = releases.reduce((sum: number, release: any) => {
|
||||||
return sum + (release.assets?.reduce((s: number, a: any) => s + (a.download_count || 0), 0) || 0);
|
return (sum +
|
||||||
|
(release.assets?.reduce((s: number, a: any) => s + (a.download_count || 0), 0) || 0));
|
||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
const topLangs = Object.entries(languages)
|
const topLangs = Object.entries(languages)
|
||||||
@@ -130,9 +80,11 @@ export function AboutPage({ version }: AboutPageProps) {
|
|||||||
stars: repoData.stargazers_count,
|
stars: repoData.stargazers_count,
|
||||||
forks: repoData.forks_count,
|
forks: repoData.forks_count,
|
||||||
createdAt: repoData.created_at,
|
createdAt: repoData.created_at,
|
||||||
|
description: repoData.description,
|
||||||
totalDownloads,
|
totalDownloads,
|
||||||
latestDownloads,
|
latestDownloads,
|
||||||
languages: topLangs
|
latestVersion,
|
||||||
|
languages: topLangs,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -150,28 +102,6 @@ export function AboutPage({ version }: AboutPageProps) {
|
|||||||
};
|
};
|
||||||
fetchRepoStats();
|
fetchRepoStats();
|
||||||
}, []);
|
}, []);
|
||||||
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 formatTimeAgo = (dateString: string): string => {
|
const formatTimeAgo = (dateString: string): string => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const updated = new Date(dateString);
|
const updated = new Date(dateString);
|
||||||
@@ -179,13 +109,13 @@ export function AboutPage({ version }: AboutPageProps) {
|
|||||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||||
const diffMonths = Math.floor(diffDays / 30);
|
const diffMonths = Math.floor(diffDays / 30);
|
||||||
if (diffDays === 0)
|
if (diffDays === 0)
|
||||||
return 'today';
|
return "today";
|
||||||
if (diffDays === 1)
|
if (diffDays === 1)
|
||||||
return '1d';
|
return "1d";
|
||||||
if (diffDays < 30)
|
if (diffDays < 30)
|
||||||
return `${diffDays}d`;
|
return `${diffDays}d`;
|
||||||
if (diffMonths === 1)
|
if (diffMonths === 1)
|
||||||
return '1mo';
|
return "1mo";
|
||||||
if (diffMonths < 12)
|
if (diffMonths < 12)
|
||||||
return `${diffMonths}mo`;
|
return `${diffMonths}mo`;
|
||||||
const diffYears = Math.floor(diffMonths / 12);
|
const diffYears = Math.floor(diffMonths / 12);
|
||||||
@@ -198,269 +128,260 @@ export function AboutPage({ version }: AboutPageProps) {
|
|||||||
return num.toString();
|
return num.toString();
|
||||||
};
|
};
|
||||||
const getLangColor = (lang: string): string => {
|
const getLangColor = (lang: string): string => {
|
||||||
return langColors[lang] || '#858585';
|
return langColors[lang] || "#858585";
|
||||||
};
|
};
|
||||||
const handleSubmit = () => {
|
const getRepoDescription = (repoName: string): string => {
|
||||||
const title = activeTab === "bug_report"
|
return repoStats[repoName]?.description || "";
|
||||||
? `[Bug Report] ${problem.substring(0, 50)}${problem.length > 50 ? "..." : ""}`
|
|
||||||
: `[Feature Request] ${featureDesc.substring(0, 50)}${featureDesc.length > 50 ? "..." : ""}`;
|
|
||||||
let bodyContent = "";
|
|
||||||
if (activeTab === "bug_report") {
|
|
||||||
const contextContent = bugContext.trim() ? bugContext.trim() : "Type here or send screenshot/recording";
|
|
||||||
bodyContent = `### [Bug Report]
|
|
||||||
|
|
||||||
#### Problem
|
|
||||||
${problem || "Type here"}
|
|
||||||
|
|
||||||
#### Type
|
|
||||||
${bugType}
|
|
||||||
|
|
||||||
#### Spotify URL
|
|
||||||
${spotifyUrl || "Type here"}
|
|
||||||
|
|
||||||
#### Additional Context
|
|
||||||
${contextContent}
|
|
||||||
|
|
||||||
#### Environment
|
|
||||||
- SpotiFLAC Version: ${version}
|
|
||||||
- OS: ${os}
|
|
||||||
- Location: ${location}`;
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
const contextContent = featureContext.trim() ? featureContext.trim() : "Type here or send screenshot/recording";
|
|
||||||
bodyContent = `### [Feature Request]
|
|
||||||
|
|
||||||
#### Description
|
|
||||||
${featureDesc || "Type here"}
|
|
||||||
|
|
||||||
#### Use Case
|
|
||||||
${useCase || "Type here"}
|
|
||||||
|
|
||||||
#### Additional Context
|
|
||||||
${contextContent}`;
|
|
||||||
}
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
title: title,
|
|
||||||
body: bodyContent
|
|
||||||
});
|
|
||||||
const url = `https://github.com/afkarxyz/SpotiFLAC/issues/new?${params.toString()}`;
|
|
||||||
openExternal(url);
|
|
||||||
};
|
};
|
||||||
return (<div className={`flex flex-col space-y-4 ${activeTab === "faq" ? "h-[calc(100vh-10rem)]" : ""}`}>
|
return (<div className="flex flex-col space-y-4">
|
||||||
<div className="flex items-center justify-between shrink-0">
|
<div className="flex items-center justify-between shrink-0">
|
||||||
<h2 className="text-2xl font-bold tracking-tight">About</h2>
|
<h2 className="text-2xl font-bold tracking-tight">About</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2 border-b shrink-0">
|
<div className="flex gap-2 border-b shrink-0">
|
||||||
<Button variant={activeTab === "bug_report" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("bug_report")} className="rounded-b-none">
|
<Button variant={activeTab === "projects" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("projects")} className="rounded-b-none">
|
||||||
<Bug className="h-4 w-4"/>
|
<Blocks className="h-4 w-4"/>
|
||||||
Bug Report
|
Other Projects
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant={activeTab === "feature_request" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("feature_request")} className="rounded-b-none">
|
<Button variant={activeTab === "support" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("support")} className="rounded-b-none">
|
||||||
<Lightbulb className="h-4 w-4"/>
|
<Heart className="h-4 w-4"/>
|
||||||
Feature Request
|
Support Me
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant={activeTab === "faq" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("faq")} className="rounded-b-none">
|
</div>
|
||||||
<CircleHelp className="h-4 w-4"/>
|
|
||||||
FAQ
|
|
||||||
</Button>
|
|
||||||
<Button variant={activeTab === "projects" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("projects")} className="rounded-b-none">
|
|
||||||
<Blocks className="h-4 w-4"/>
|
|
||||||
Other Projects
|
|
||||||
</Button>
|
|
||||||
<Button variant={activeTab === "support" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("support")} className="rounded-b-none">
|
|
||||||
<Heart className="h-4 w-4"/>
|
|
||||||
Support Us
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={`flex-1 min-h-0 ${activeTab === "faq" ? "overflow-hidden" : ""}`}>
|
<div className="flex-1 min-h-0">
|
||||||
{activeTab === "bug_report" && (<div className="flex flex-col">
|
|
||||||
<div className="space-y-4 pt-4 flex flex-col">
|
|
||||||
<div className="mt-4 pr-2">
|
{activeTab === "projects" && (<div className="p-1 pr-2">
|
||||||
<div className="grid md:grid-cols-3 gap-6">
|
<div className="grid gap-2 grid-cols-4">
|
||||||
<div className="space-y-2 flex flex-col">
|
<div className="flex flex-col gap-2 h-full">
|
||||||
<Label>Problem</Label>
|
<Card className="hover:bg-muted/50 hover:border-primary/50 transition-colors cursor-pointer flex-1" onClick={() => openExternal("https://exyezed.qzz.io/")}>
|
||||||
<Textarea className="h-56 resize-none" placeholder="Describe the problem..." value={problem} onChange={e => setProblem(e.target.value)}/>
|
<CardHeader>
|
||||||
</div>
|
<CardTitle>Browser Extensions & Scripts</CardTitle>
|
||||||
<div className="space-y-2 flex flex-col">
|
<CardDescription className="flex gap-3 pt-2">
|
||||||
<Label>Additional Context</Label>
|
<img src={AudioTTSProIcon} className="h-8 w-8 rounded-md shadow-sm" alt="AudioTTS Pro"/>
|
||||||
<DragDropMedia className="min-h-[14rem]" value={bugContext} onChange={setBugContext}/>
|
<img src={ChatGPTTTSIcon} className="h-8 w-8 rounded-md shadow-sm" alt="ChatGPT TTS"/>
|
||||||
</div>
|
<img src={XIcon} className="h-8 w-8 rounded-md shadow-sm" alt="X"/>
|
||||||
<div className="space-y-4 flex flex-col">
|
<img src={XProIcon} className="h-8 w-8 rounded-md shadow-sm" alt="X Pro"/>
|
||||||
<div className="space-y-2">
|
</CardDescription>
|
||||||
<Label>Type</Label>
|
</CardHeader>
|
||||||
<ToggleGroup type="single" value={bugType} onValueChange={(val) => {
|
</Card>
|
||||||
if (val)
|
<Card className="hover:bg-muted/50 hover:border-primary/50 transition-colors cursor-pointer flex-1" onClick={() => openExternal("https://spotubedl.com/")}>
|
||||||
setBugType(val);
|
<CardHeader>
|
||||||
}} className="justify-start w-full cursor-pointer">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<ToggleGroupItem value="Track" className="flex-1 cursor-pointer" aria-label="Toggle track">Track</ToggleGroupItem>
|
<img src={SpotubeDLIcon} className="h-5 w-5" alt="SpotubeDL"/>{" "}
|
||||||
<ToggleGroupItem value="Album" className="flex-1 cursor-pointer" aria-label="Toggle album">Album</ToggleGroupItem>
|
SpotubeDL
|
||||||
<ToggleGroupItem value="Playlist" className="flex-1 cursor-pointer" aria-label="Toggle playlist">Playlist</ToggleGroupItem>
|
</CardTitle>
|
||||||
<ToggleGroupItem value="Artist" className="flex-1 cursor-pointer" aria-label="Toggle artist">Artist</ToggleGroupItem>
|
<CardDescription>
|
||||||
</ToggleGroup>
|
Download Spotify Tracks, Albums, Playlists as MP3/OGG/Opus
|
||||||
</div>
|
with High Quality.
|
||||||
<div className="space-y-2">
|
</CardDescription>
|
||||||
<Label>Spotify URL</Label>
|
</CardHeader>
|
||||||
<Input placeholder="https://open.spotify.com/..." value={spotifyUrl} onChange={e => setSpotifyUrl(e.target.value)}/>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<Card className="hover:bg-muted/50 hover:border-primary/50 transition-colors cursor-pointer" onClick={() => openExternal("https://github.com/afkarxyz/SpotiDownloader")}>
|
||||||
</div>
|
<CardHeader>
|
||||||
</div>
|
<div className="flex justify-between items-start mb-2">
|
||||||
|
<img src={SpotiDownloaderIcon} className="h-6 w-6 shrink-0" alt="SpotiDownloader"/>
|
||||||
|
{repoStats["SpotiDownloader"]?.latestVersion && (<span className="text-[10px] bg-primary text-primary-foreground px-1.5 py-0.5 rounded-sm font-mono font-semibold max-w-[80px] truncate">
|
||||||
|
{repoStats["SpotiDownloader"].latestVersion}
|
||||||
|
</span>)}
|
||||||
|
</div>
|
||||||
|
<CardTitle className="leading-tight">
|
||||||
|
SpotiDownloader
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{getRepoDescription("SpotiDownloader")}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
{repoStats["SpotiDownloader"] && (<CardContent className="space-y-3">
|
||||||
|
<div className="flex flex-wrap gap-2 text-xs">
|
||||||
|
{repoStats["SpotiDownloader"].languages?.map((lang: string) => (<span key={lang} className="px-2 py-0.5 rounded-full font-medium" style={{
|
||||||
|
backgroundColor: getLangColor(lang) + "20",
|
||||||
|
color: getLangColor(lang),
|
||||||
|
}}>
|
||||||
|
{lang}
|
||||||
|
</span>))}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-center pt-4 shrink-0">
|
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||||
<Button className="w-[200px] cursor-pointer gap-2" onClick={handleSubmit}>
|
<span className="flex items-center gap-1">
|
||||||
<ExternalLink className="h-4 w-4"/> Create Issue on GitHub
|
<Star className="h-3.5 w-3.5 fill-amber-500 text-amber-500"/>{" "}
|
||||||
</Button>
|
{formatNumber(repoStats["SpotiDownloader"].stars)}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<GitFork className="h-3.5 w-3.5"/>{" "}
|
||||||
|
{repoStats["SpotiDownloader"].forks}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Clock className="h-3.5 w-3.5"/>{" "}
|
||||||
|
{formatTimeAgo(repoStats["SpotiDownloader"].createdAt)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>)}
|
<div className="flex flex-col gap-1 text-xs text-muted-foreground items-start">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
{activeTab === "feature_request" && (<div className="flex flex-col">
|
<Download className="h-3.5 w-3.5"/> TOTAL:{" "}
|
||||||
<div className="space-y-4 pt-4 flex flex-col">
|
{formatNumber(repoStats["SpotiDownloader"].totalDownloads)}
|
||||||
<div className="mt-4 pr-2">
|
</span>
|
||||||
<div className="grid md:grid-cols-3 gap-6">
|
<span className="flex items-center gap-1 text-green-600 dark:text-green-400">
|
||||||
<div className="space-y-2 flex flex-col">
|
<Download className="h-3.5 w-3.5"/> LATEST:{" "}
|
||||||
<Label>Description</Label>
|
{formatNumber(repoStats["SpotiDownloader"].latestDownloads)}
|
||||||
<Textarea className="h-56 resize-none" placeholder="Describe your feature request..." value={featureDesc} onChange={e => setFeatureDesc(e.target.value)}/>
|
</span>
|
||||||
</div>
|
|
||||||
<div className="space-y-2 flex-col">
|
|
||||||
<Label>Use Case</Label>
|
|
||||||
<Textarea className="h-56 resize-none" placeholder="How would this feature be useful?" value={useCase} onChange={e => setUseCase(e.target.value)}/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2 flex-col">
|
|
||||||
<Label>Additional Context</Label>
|
|
||||||
<DragDropMedia className="min-h-[14rem]" value={featureContext} onChange={setFeatureContext}/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-center pt-4 shrink-0">
|
</CardContent>)}
|
||||||
<Button className="w-[200px] cursor-pointer gap-2" onClick={handleSubmit}>
|
</Card>
|
||||||
<ExternalLink className="h-4 w-4"/> Create Issue on GitHub
|
<Card className="gap-2 hover:bg-muted/50 hover:border-primary/50 transition-colors cursor-pointer" onClick={() => openExternal("https://github.com/spotiverse/SpotiFLAC-Next")}>
|
||||||
</Button>
|
<CardHeader>
|
||||||
|
<div className="flex justify-between items-start mb-2">
|
||||||
|
<img src={SpotiFLACNextIcon} className="h-6 w-6 shrink-0" alt="SpotiFLAC Next"/>
|
||||||
|
{repoStats["SpotiFLAC-Next"]?.latestVersion && (<span className="text-[10px] bg-primary text-primary-foreground px-1.5 py-0.5 rounded-sm font-mono font-semibold max-w-[80px] truncate">
|
||||||
|
{repoStats["SpotiFLAC-Next"].latestVersion}
|
||||||
|
</span>)}
|
||||||
|
</div>
|
||||||
|
<CardTitle className="leading-tight">
|
||||||
|
SpotiFLAC Next
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{getRepoDescription("SpotiFLAC-Next")}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
{repoStats["SpotiFLAC-Next"] && (<CardContent className="space-y-2">
|
||||||
|
{repoStats["SpotiFLAC-Next"].languages?.length > 0 && (<div className="flex flex-wrap gap-2 text-xs">
|
||||||
|
{repoStats["SpotiFLAC-Next"].languages.map((lang: string) => (<span key={lang} className="px-2 py-0.5 rounded-full font-medium" style={{
|
||||||
|
backgroundColor: getLangColor(lang) + "20",
|
||||||
|
color: getLangColor(lang),
|
||||||
|
}}>
|
||||||
|
{lang}
|
||||||
|
</span>))}
|
||||||
|
</div>)}
|
||||||
|
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Star className="h-3.5 w-3.5 fill-amber-500 text-amber-500"/>{" "}
|
||||||
|
{formatNumber(repoStats["SpotiFLAC-Next"].stars)}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<GitFork className="h-3.5 w-3.5"/>{" "}
|
||||||
|
{repoStats["SpotiFLAC-Next"].forks}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Clock className="h-3.5 w-3.5"/>{" "}
|
||||||
|
{formatTimeAgo(repoStats["SpotiFLAC-Next"].createdAt)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>)}
|
<div className="rounded-md border border-sky-500/25 bg-sky-500/8 px-3 py-2">
|
||||||
|
<div className="mb-1 flex items-center gap-1.5 text-xs font-semibold text-sky-700 dark:text-sky-300">
|
||||||
{activeTab === "faq" && (<ScrollArea className="h-full">
|
<Info className="h-3.5 w-3.5"/>
|
||||||
<div className="p-1 pr-4">
|
Note
|
||||||
<Card>
|
</div>
|
||||||
<CardHeader>
|
<p className="text-xs leading-relaxed text-sky-700 dark:text-sky-300">
|
||||||
<CardTitle>Frequently Asked Questions</CardTitle>
|
SpotiFLAC Next is a separate project created as a thank-you
|
||||||
</CardHeader>
|
to everyone who has supported SpotiFLAC on Ko-fi.
|
||||||
<CardContent className="space-y-6">
|
</p>
|
||||||
{faqs.map((faq, index) => (<div key={index} className="space-y-2">
|
|
||||||
<h3 className="font-medium text-base text-foreground/90">{faq.q}</h3>
|
|
||||||
<p className="text-sm text-muted-foreground leading-relaxed">{faq.a}</p>
|
|
||||||
</div>))}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>)}
|
</CardContent>)}
|
||||||
|
</Card>
|
||||||
|
<Card className="hover:bg-muted/50 hover:border-primary/50 transition-colors cursor-pointer" onClick={() => openExternal("https://github.com/afkarxyz/Twitter-X-Media-Batch-Downloader")}>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex justify-between items-start mb-2">
|
||||||
|
<img src={XBatchDLIcon} className="h-6 w-6 shrink-0" alt="Twitter/X Media Batch Downloader"/>
|
||||||
|
{repoStats["Twitter-X-Media-Batch-Downloader"]?.latestVersion && (<span className="text-[10px] bg-primary text-primary-foreground px-1.5 py-0.5 rounded-sm font-mono font-semibold max-w-[80px] truncate">
|
||||||
|
{repoStats["Twitter-X-Media-Batch-Downloader"].latestVersion}
|
||||||
|
</span>)}
|
||||||
|
</div>
|
||||||
|
<CardTitle className="leading-tight">
|
||||||
|
Twitter/X Media Batch Downloader
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{getRepoDescription("Twitter-X-Media-Batch-Downloader")}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
{repoStats["Twitter-X-Media-Batch-Downloader"] && (<CardContent className="space-y-3">
|
||||||
|
<div className="flex flex-wrap gap-2 text-xs">
|
||||||
|
{repoStats["Twitter-X-Media-Batch-Downloader"].languages?.map((lang: string) => (<span key={lang} className="px-2 py-0.5 rounded-full font-medium" style={{
|
||||||
|
backgroundColor: getLangColor(lang) + "20",
|
||||||
|
color: getLangColor(lang),
|
||||||
|
}}>
|
||||||
|
{lang}
|
||||||
|
</span>))}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Star className="h-3.5 w-3.5 fill-amber-500 text-amber-500"/>{" "}
|
||||||
|
{formatNumber(repoStats["Twitter-X-Media-Batch-Downloader"].stars)}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<GitFork className="h-3.5 w-3.5"/>{" "}
|
||||||
|
{repoStats["Twitter-X-Media-Batch-Downloader"].forks}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Clock className="h-3.5 w-3.5"/>{" "}
|
||||||
|
{formatTimeAgo(repoStats["Twitter-X-Media-Batch-Downloader"]
|
||||||
|
.createdAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1 text-xs text-muted-foreground items-start">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Download className="h-3.5 w-3.5"/> TOTAL:{" "}
|
||||||
|
{formatNumber(repoStats["Twitter-X-Media-Batch-Downloader"]
|
||||||
|
.totalDownloads)}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1 text-green-600 dark:text-green-400">
|
||||||
|
<Download className="h-3.5 w-3.5"/> LATEST:{" "}
|
||||||
|
{formatNumber(repoStats["Twitter-X-Media-Batch-Downloader"]
|
||||||
|
.latestDownloads)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>)}
|
||||||
|
|
||||||
{activeTab === "projects" && (<div className="p-1 pr-2">
|
{activeTab === "support" && (<div className="flex flex-col items-center justify-center p-4 space-y-6">
|
||||||
<div className="grid gap-2 grid-cols-4">
|
<div className="flex flex-col md:flex-row w-full max-w-3xl bg-card rounded-xl border shadow-sm">
|
||||||
<div className="flex flex-col gap-2 h-full">
|
|
||||||
<Card className="hover:bg-muted/50 hover:border-primary/50 transition-colors cursor-pointer flex-1" onClick={() => openExternal("https://exyezed.cc/")}>
|
<div className="flex-1 p-6 flex flex-col items-center justify-between border-b md:border-b-0 md:border-r space-y-6">
|
||||||
<CardHeader>
|
<div className="flex flex-col items-center space-y-4">
|
||||||
<CardTitle>Browser Extensions & Scripts</CardTitle>
|
<div className="h-32 flex items-center justify-center w-full relative">
|
||||||
<CardDescription className="flex gap-3 pt-2">
|
<img src={KofiLogo} className="w-72 absolute pointer-events-none" alt="Ko-fi"/>
|
||||||
<img src={AudioTTSProIcon} className="h-8 w-8 rounded-md shadow-sm" alt="AudioTTS Pro"/>
|
</div>
|
||||||
<img src={ChatGPTTTSIcon} className="h-8 w-8 rounded-md shadow-sm" alt="ChatGPT TTS"/>
|
<h4 className="font-semibold text-foreground">Support via Ko-fi</h4>
|
||||||
<img src={XProIcon} className="h-8 w-8 rounded-md shadow-sm" alt="X Pro"/>
|
<p className="text-sm text-muted-foreground text-center px-4">
|
||||||
</CardDescription>
|
Enjoying the project? You can support ongoing development by buying me a coffee.
|
||||||
</CardHeader>
|
</p>
|
||||||
</Card>
|
|
||||||
<Card className="hover:bg-muted/50 hover:border-primary/50 transition-colors cursor-pointer flex-1" onClick={() => openExternal("https://spotubedl.com/")}>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2"><img src={SpotubeDLIcon} className="h-5 w-5" alt="SpotubeDL"/> SpotubeDL</CardTitle>
|
|
||||||
<CardDescription>Download Spotify Tracks, Albums, Playlists as MP3/OGG/Opus with High Quality.</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
<Card className="hover:bg-muted/50 hover:border-primary/50 transition-colors cursor-pointer" onClick={() => openExternal("https://github.com/afkarxyz/SpotiDownloader")}>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2"><img src={SpotiDownloaderIcon} className="h-5 w-5" alt="SpotiDownloader"/> SpotiDownloader</CardTitle>
|
|
||||||
<CardDescription>Get Spotify tracks in MP3 and FLAC via spotidownloader.com</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
{repoStats['SpotiDownloader'] && (<CardContent className="space-y-3">
|
|
||||||
<div className="flex flex-wrap gap-2 text-xs">
|
|
||||||
{repoStats['SpotiDownloader'].languages?.map((lang: string) => (<span key={lang} className="px-2 py-0.5 rounded-full font-medium" style={{ backgroundColor: getLangColor(lang) + '20', color: getLangColor(lang) }}>{lang}</span>))}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
|
||||||
<span className="flex items-center gap-1"><Star className="h-3.5 w-3.5 fill-amber-500 text-amber-500"/> {formatNumber(repoStats['SpotiDownloader'].stars)}</span>
|
|
||||||
<span className="flex items-center gap-1"><GitFork className="h-3.5 w-3.5"/> {repoStats['SpotiDownloader'].forks}</span>
|
|
||||||
<span className="flex items-center gap-1"><Clock className="h-3.5 w-3.5"/> {formatTimeAgo(repoStats['SpotiDownloader'].createdAt)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1 text-xs text-muted-foreground items-start">
|
|
||||||
<span className="flex items-center gap-1"><Download className="h-3.5 w-3.5"/> TOTAL: {formatNumber(repoStats['SpotiDownloader'].totalDownloads)}</span>
|
|
||||||
<span className="flex items-center gap-1 text-green-600 dark:text-green-400"><Download className="h-3.5 w-3.5"/> LATEST: {formatNumber(repoStats['SpotiDownloader'].latestDownloads)}</span>
|
|
||||||
</div>
|
|
||||||
</CardContent>)}
|
|
||||||
</Card>
|
|
||||||
<Card className="hover:bg-muted/50 hover:border-primary/50 transition-colors cursor-pointer" onClick={() => openExternal("https://github.com/spotiverse/SpotiFLAC-Next")}>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2"><img src={SpotiFLACNextIcon} className="h-5 w-5" alt="SpotiFLAC Next"/> SpotiFLAC Next</CardTitle>
|
|
||||||
<CardDescription>Get Spotify tracks in Hi-Res lossless FLACs — no account required.</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
{repoStats['SpotiFLAC-Next'] && (<CardContent className="space-y-3">
|
|
||||||
<div className="flex flex-wrap gap-2 text-xs">
|
|
||||||
{repoStats['SpotiFLAC-Next'].languages?.map((lang: string) => (<span key={lang} className="px-2 py-0.5 rounded-full font-medium" style={{ backgroundColor: getLangColor(lang) + '20', color: getLangColor(lang) }}>{lang}</span>))}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
|
||||||
<span className="flex items-center gap-1"><Star className="h-3.5 w-3.5 fill-amber-500 text-amber-500"/> {formatNumber(repoStats['SpotiFLAC-Next'].stars)}</span>
|
|
||||||
<span className="flex items-center gap-1"><GitFork className="h-3.5 w-3.5"/> {repoStats['SpotiFLAC-Next'].forks}</span>
|
|
||||||
<span className="flex items-center gap-1"><Clock className="h-3.5 w-3.5"/> {formatTimeAgo(repoStats['SpotiFLAC-Next'].createdAt)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1 text-xs text-muted-foreground items-start">
|
|
||||||
<span className="flex items-center gap-1"><Download className="h-3.5 w-3.5"/> TOTAL: {formatNumber(repoStats['SpotiFLAC-Next'].totalDownloads)}</span>
|
|
||||||
<span className="flex items-center gap-1 text-green-600 dark:text-green-400"><Download className="h-3.5 w-3.5"/> LATEST: {formatNumber(repoStats['SpotiFLAC-Next'].latestDownloads)}</span>
|
|
||||||
</div>
|
|
||||||
</CardContent>)}
|
|
||||||
</Card>
|
|
||||||
<Card className="hover:bg-muted/50 hover:border-primary/50 transition-colors cursor-pointer" onClick={() => openExternal("https://github.com/afkarxyz/Twitter-X-Media-Batch-Downloader")}>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2"><img src={XBatchDLIcon} className="h-5 w-5" alt="Twitter/X Media Batch Downloader"/> Twitter/X Media Batch Downloader</CardTitle>
|
|
||||||
<CardDescription>A GUI tool to download original-quality images and videos from Twitter/X accounts, powered by gallery-dl by @mikf</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
{repoStats['Twitter-X-Media-Batch-Downloader'] && (<CardContent className="space-y-3">
|
|
||||||
<div className="flex flex-wrap gap-2 text-xs">
|
|
||||||
{repoStats['Twitter-X-Media-Batch-Downloader'].languages?.map((lang: string) => (<span key={lang} className="px-2 py-0.5 rounded-full font-medium" style={{ backgroundColor: getLangColor(lang) + '20', color: getLangColor(lang) }}>{lang}</span>))}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
|
||||||
<span className="flex items-center gap-1"><Star className="h-3.5 w-3.5 fill-amber-500 text-amber-500"/> {formatNumber(repoStats['Twitter-X-Media-Batch-Downloader'].stars)}</span>
|
|
||||||
<span className="flex items-center gap-1"><GitFork className="h-3.5 w-3.5"/> {repoStats['Twitter-X-Media-Batch-Downloader'].forks}</span>
|
|
||||||
<span className="flex items-center gap-1"><Clock className="h-3.5 w-3.5"/> {formatTimeAgo(repoStats['Twitter-X-Media-Batch-Downloader'].createdAt)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1 text-xs text-muted-foreground items-start">
|
|
||||||
<span className="flex items-center gap-1"><Download className="h-3.5 w-3.5"/> TOTAL: {formatNumber(repoStats['Twitter-X-Media-Batch-Downloader'].totalDownloads)}</span>
|
|
||||||
<span className="flex items-center gap-1 text-green-600 dark:text-green-400"><Download className="h-3.5 w-3.5"/> LATEST: {formatNumber(repoStats['Twitter-X-Media-Batch-Downloader'].latestDownloads)}</span>
|
|
||||||
</div>
|
|
||||||
</CardContent>)}
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>)}
|
|
||||||
|
|
||||||
|
|
||||||
{activeTab === "support" && (<div className="flex flex-col items-center justify-center p-8 space-y-8">
|
|
||||||
<div className="text-center space-y-2">
|
|
||||||
<h3 className="text-2xl font-bold tracking-tight">Support Our Work</h3>
|
|
||||||
<p className="text-muted-foreground max-w-[500px]">
|
|
||||||
If this software is useful and brings you value, consider supporting the project by buying me a coffee. Your support helps keep development going.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<Button className="h-10 w-full text-sm font-semibold text-white gap-2 group bg-[#72a4f2] hover:bg-[#5f8cd6]" onClick={() => openExternal("https://ko-fi.com/afkarxyz")}>
|
||||||
|
<img src={KofiSvg} className="w-5 h-5 shrink-0" alt="" aria-hidden="true"/>
|
||||||
|
Support me on Ko-fi
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid sm:grid-cols-2 gap-4 w-full max-w-lg">
|
|
||||||
<Button size="lg" className="h-16 text-lg font-semibold text-white gap-3 group" style={{ backgroundColor: "#72a4f2" }} onClick={() => openExternal("https://ko-fi.com/afkarxyz")}>
|
<div className="flex-1 p-6 flex flex-col items-center justify-between space-y-6">
|
||||||
<img src={KofiLogo} className="h-8 w-8 transition-transform group-hover:scale-110" alt="Ko-fi"/>
|
<div className="flex flex-col items-center space-y-4 w-full">
|
||||||
Support me on Ko-fi
|
<div className="h-32 flex items-center justify-center">
|
||||||
</Button>
|
<div className="p-2 bg-white rounded-xl shadow-sm border">
|
||||||
|
<img src={UsdtBarcode} className="w-24 h-24 object-contain" alt="USDT Barcode"/>
|
||||||
<Button size="lg" className="h-16 text-lg font-semibold text-black gap-3 group" style={{ backgroundColor: "#ffdd00" }} onClick={() => openExternal("https://buymeacoffee.com/afkarxyz")}>
|
</div>
|
||||||
<img src={BmcLogo} className="h-6 w-6 transition-transform group-hover:scale-110" alt="Buy Me a Coffee"/>
|
</div>
|
||||||
Buy Me a Coffee
|
<h4 className="font-semibold text-foreground">USDT (TRC20)</h4>
|
||||||
</Button>
|
<p className="text-sm text-muted-foreground text-center px-4">
|
||||||
|
Crypto donations are also accepted. Scan the QR code or copy the address.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>)}
|
<div className="flex items-center gap-2 bg-muted/50 pl-3 pr-1.5 py-1.5 rounded-lg border w-full justify-between h-10">
|
||||||
</div>
|
<code className="text-xs font-mono text-muted-foreground truncate" title="THnzAAwZgp2Sq5CAXLP2njQDhTvgZG9EWs">
|
||||||
|
THnzAAwZgp2Sq5CAXLP2njQDhTvgZG9EWs
|
||||||
|
</code>
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0 hover:bg-background" onClick={() => {
|
||||||
|
navigator.clipboard.writeText("THnzAAwZgp2Sq5CAXLP2njQDhTvgZG9EWs");
|
||||||
|
setCopiedUsdt(true);
|
||||||
|
setTimeout(() => setCopiedUsdt(false), 500);
|
||||||
|
}}>
|
||||||
|
{copiedUsdt ? <CircleCheck className="h-3.5 w-3.5 text-green-500"/> : <Copy className="h-3.5 w-3.5"/>}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>)}
|
||||||
|
</div>
|
||||||
</div>);
|
</div>);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,12 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
|
|||||||
import { SearchAndSort } from "./SearchAndSort";
|
import { SearchAndSort } from "./SearchAndSort";
|
||||||
import { TrackList } from "./TrackList";
|
import { TrackList } from "./TrackList";
|
||||||
import { DownloadProgress } from "./DownloadProgress";
|
import { DownloadProgress } from "./DownloadProgress";
|
||||||
|
import { getSettings } from "@/lib/settings";
|
||||||
|
import { downloadCover } from "@/lib/api";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||||
|
import { joinPath, sanitizePath } from "@/lib/utils";
|
||||||
|
import { parseTemplate, type TemplateData } from "@/lib/settings";
|
||||||
import type { TrackMetadata, TrackAvailability } from "@/types/api";
|
import type { TrackMetadata, TrackAvailability } from "@/types/api";
|
||||||
interface AlbumInfoProps {
|
interface AlbumInfoProps {
|
||||||
albumInfo: {
|
albumInfo: {
|
||||||
@@ -48,9 +54,9 @@ interface AlbumInfoProps {
|
|||||||
isBulkDownloadingLyrics?: boolean;
|
isBulkDownloadingLyrics?: boolean;
|
||||||
onSearchChange: (value: string) => void;
|
onSearchChange: (value: string) => void;
|
||||||
onSortChange: (value: string) => void;
|
onSortChange: (value: string) => void;
|
||||||
onToggleTrack: (isrc: string) => void;
|
onToggleTrack: (id: string) => void;
|
||||||
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
|
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
|
||||||
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => void;
|
onDownloadTrack: (id: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => void;
|
||||||
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
|
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
|
||||||
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
|
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
|
||||||
onCheckAvailability?: (spotifyId: string) => void;
|
onCheckAvailability?: (spotifyId: string) => void;
|
||||||
@@ -70,6 +76,65 @@ interface AlbumInfoProps {
|
|||||||
onBack?: () => void;
|
onBack?: () => void;
|
||||||
}
|
}
|
||||||
export function AlbumInfo({ albumInfo, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onPageChange, onArtistClick, onTrackClick, onBack, }: AlbumInfoProps) {
|
export function AlbumInfo({ albumInfo, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onPageChange, onArtistClick, onTrackClick, onBack, }: AlbumInfoProps) {
|
||||||
|
const settings = getSettings();
|
||||||
|
const [downloadingAlbumCover, setDownloadingAlbumCover] = useState(false);
|
||||||
|
const handleDownloadAlbumCover = async () => {
|
||||||
|
if (!albumInfo.images)
|
||||||
|
return;
|
||||||
|
setDownloadingAlbumCover(true);
|
||||||
|
try {
|
||||||
|
const os = settings.operatingSystem;
|
||||||
|
let outputDir = settings.downloadPath;
|
||||||
|
const albumName = albumInfo.name;
|
||||||
|
const artistName = albumInfo.artists;
|
||||||
|
const placeholder = "__SLASH_PLACEHOLDER__";
|
||||||
|
const templateData: TemplateData = {
|
||||||
|
artist: artistName?.replace(/\//g, placeholder),
|
||||||
|
album: albumName?.replace(/\//g, placeholder),
|
||||||
|
album_artist: artistName?.replace(/\//g, placeholder),
|
||||||
|
title: albumName?.replace(/\//g, placeholder),
|
||||||
|
year: albumInfo.release_date?.substring(0, 4),
|
||||||
|
date: albumInfo.release_date,
|
||||||
|
};
|
||||||
|
if (settings.folderTemplate) {
|
||||||
|
const folderPath = parseTemplate(settings.folderTemplate, templateData);
|
||||||
|
if (folderPath) {
|
||||||
|
const parts = folderPath.split("/").filter((p: string) => p.trim());
|
||||||
|
for (const part of parts) {
|
||||||
|
outputDir = joinPath(os, outputDir, sanitizePath(part.replace(new RegExp(placeholder, "g"), " "), os));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const response = await downloadCover({
|
||||||
|
cover_url: albumInfo.images,
|
||||||
|
track_name: albumName,
|
||||||
|
artist_name: "",
|
||||||
|
album_name: "",
|
||||||
|
album_artist: "",
|
||||||
|
release_date: "",
|
||||||
|
output_dir: outputDir,
|
||||||
|
filename_format: "title",
|
||||||
|
track_number: false,
|
||||||
|
position: 0,
|
||||||
|
disc_number: 0,
|
||||||
|
});
|
||||||
|
if (response.success) {
|
||||||
|
if (response.already_exists)
|
||||||
|
toast.info("Cover already exists");
|
||||||
|
else
|
||||||
|
toast.success("Separate album cover downloaded");
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
toast.error(response.error || "Failed to download cover");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "Failed to download cover");
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
setDownloadingAlbumCover(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
return (<div className="space-y-6">
|
return (<div className="space-y-6">
|
||||||
<Card className="relative">
|
<Card className="relative">
|
||||||
{onBack && (<div className="absolute top-4 right-4 z-10">
|
{onBack && (<div className="absolute top-4 right-4 z-10">
|
||||||
@@ -79,7 +144,19 @@ export function AlbumInfo({ albumInfo, trackList, searchQuery, sortBy, selectedT
|
|||||||
</div>)}
|
</div>)}
|
||||||
<CardContent className="px-6">
|
<CardContent className="px-6">
|
||||||
<div className="flex gap-6 items-start">
|
<div className="flex gap-6 items-start">
|
||||||
{albumInfo.images && (<img src={albumInfo.images} alt={albumInfo.name} className="w-48 h-48 rounded-md shadow-lg object-cover"/>)}
|
{albumInfo.images && (<div className="relative group shrink-0 w-48 h-48">
|
||||||
|
<img src={albumInfo.images} alt={albumInfo.name} className="w-48 h-48 rounded-md shadow-lg object-cover"/>
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity rounded-md">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button variant="secondary" size="icon" className="h-9 w-9 shadow-lg" onClick={handleDownloadAlbumCover} disabled={downloadingAlbumCover}>
|
||||||
|
{downloadingAlbumCover ? <Spinner /> : <ImageDown className="h-4 w-4"/>}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent><p>Download Separate Album Cover</p></TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>)}
|
||||||
<div className="flex-1 space-y-4">
|
<div className="flex-1 space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-sm font-medium">Album</p>
|
<p className="text-sm font-medium">Album</p>
|
||||||
@@ -126,7 +203,7 @@ export function AlbumInfo({ albumInfo, trackList, searchQuery, sortBy, selectedT
|
|||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>Download All Covers</p>
|
<p>Download All Separate Covers</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>)}
|
</Tooltip>)}
|
||||||
{downloadedTracks.size > 0 && (<Button onClick={onOpenFolder} variant="outline">
|
{downloadedTracks.size > 0 && (<Button onClick={onOpenFolder} variant="outline">
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { RefreshCw, CheckCircle2, XCircle, Loader2 } from "lucide-react";
|
||||||
|
import { CheckAPIStatus } from "../../wailsjs/go/main/App";
|
||||||
|
import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons";
|
||||||
|
interface ApiSource {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
const SOURCES: ApiSource[] = [
|
||||||
|
{ id: "tidal1", type: "tidal", name: "Tidal A", url: "https://hifi-one.spotisaver.net" },
|
||||||
|
{ id: "tidal2", type: "tidal", name: "Tidal B", url: "https://hifi-two.spotisaver.net" },
|
||||||
|
{ id: "tidal3", type: "tidal", name: "Tidal C", url: "https://eu-central.monochrome.tf" },
|
||||||
|
{ id: "tidal4", type: "tidal", name: "Tidal D", url: "https://us-west.monochrome.tf" },
|
||||||
|
{ id: "tidal5", type: "tidal", name: "Tidal E", url: "https://api.monochrome.tf" },
|
||||||
|
{ id: "tidal6", type: "tidal", name: "Tidal F", url: "https://monochrome-api.samidy.com" },
|
||||||
|
{ id: "tidal7", type: "tidal", name: "Tidal G", url: "https://tidal.kinoplus.online" },
|
||||||
|
{ id: "qobuz1", type: "qobuz", name: "Qobuz A", url: "https://dab.yeet.su" },
|
||||||
|
{ id: "qobuz2", type: "qobuz", name: "Qobuz B", url: "https://dabmusic.xyz" },
|
||||||
|
{ id: "qobuz3", type: "qbz", name: "Qobuz C", url: "https://qbz.afkarxyz.qzz.io" },
|
||||||
|
{ id: "amazon1", type: "amazon", name: "Amazon Music", url: "https://amzn.afkarxyz.qzz.io" },
|
||||||
|
];
|
||||||
|
export function ApiStatusTab() {
|
||||||
|
const [statuses, setStatuses] = useState<Record<string, "checking" | "online" | "offline" | "idle">>({});
|
||||||
|
const [isCheckingAll, setIsCheckingAll] = useState(false);
|
||||||
|
const checkStatus = async (sourceId: string, apiType: string, url: string) => {
|
||||||
|
setStatuses(prev => ({ ...prev, [sourceId]: "checking" }));
|
||||||
|
try {
|
||||||
|
const isOnline = await CheckAPIStatus(apiType, url);
|
||||||
|
setStatuses(prev => ({ ...prev, [sourceId]: isOnline ? "online" : "offline" }));
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
setStatuses(prev => ({ ...prev, [sourceId]: "offline" }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const checkAll = async () => {
|
||||||
|
setIsCheckingAll(true);
|
||||||
|
const promises = SOURCES.map(s => checkStatus(s.id, s.type, s.url));
|
||||||
|
await Promise.allSettled(promises);
|
||||||
|
setIsCheckingAll(false);
|
||||||
|
};
|
||||||
|
useEffect(() => {
|
||||||
|
checkAll();
|
||||||
|
}, []);
|
||||||
|
return (<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-end">
|
||||||
|
<Button variant="outline" onClick={checkAll} disabled={isCheckingAll} className="gap-2">
|
||||||
|
<RefreshCw className={`h-4 w-4 ${isCheckingAll ? "animate-spin" : ""}`}/>
|
||||||
|
Refresh All
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-4 gap-4">
|
||||||
|
{SOURCES.map((source) => {
|
||||||
|
const status = statuses[source.id] || "idle";
|
||||||
|
return (<div key={source.id} className="flex items-center justify-between p-4 border rounded-lg bg-card text-card-foreground shadow-sm">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{source.type === "tidal" ? <TidalIcon className="w-5 h-5 shrink-0 text-muted-foreground"/> : source.type === "amazon" ? <AmazonIcon className="w-5 h-5 shrink-0 text-muted-foreground"/> : <QobuzIcon className="w-5 h-5 shrink-0 text-muted-foreground"/>}
|
||||||
|
<p className="font-medium leading-none">{source.name}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center">
|
||||||
|
{status === "checking" && <Loader2 className="h-5 w-5 animate-spin text-muted-foreground"/>}
|
||||||
|
{status === "online" && <CheckCircle2 className="h-5 w-5 text-emerald-500"/>}
|
||||||
|
{status === "offline" && <XCircle className="h-5 w-5 text-destructive"/>}
|
||||||
|
{status === "idle" && <div className="h-5 w-5 rounded-full bg-muted"/>}
|
||||||
|
</div>
|
||||||
|
</div>);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>);
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ interface ArtistInfoProps {
|
|||||||
header?: string;
|
header?: string;
|
||||||
gallery?: string[];
|
gallery?: string[];
|
||||||
followers: number;
|
followers: number;
|
||||||
|
total_albums?: number;
|
||||||
genres: string[];
|
genres: string[];
|
||||||
biography?: string;
|
biography?: string;
|
||||||
verified?: boolean;
|
verified?: boolean;
|
||||||
@@ -67,9 +68,9 @@ interface ArtistInfoProps {
|
|||||||
isBulkDownloadingLyrics?: boolean;
|
isBulkDownloadingLyrics?: boolean;
|
||||||
onSearchChange: (value: string) => void;
|
onSearchChange: (value: string) => void;
|
||||||
onSortChange: (value: string) => void;
|
onSortChange: (value: string) => void;
|
||||||
onToggleTrack: (isrc: string) => void;
|
onToggleTrack: (id: string) => void;
|
||||||
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
|
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
|
||||||
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => void;
|
onDownloadTrack: (id: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => void;
|
||||||
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
|
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
|
||||||
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
|
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
|
||||||
onCheckAvailability?: (spotifyId: string) => void;
|
onCheckAvailability?: (spotifyId: string) => void;
|
||||||
@@ -99,6 +100,31 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
|
|||||||
const [downloadingGalleryIndex, setDownloadingGalleryIndex] = useState<number | null>(null);
|
const [downloadingGalleryIndex, setDownloadingGalleryIndex] = useState<number | null>(null);
|
||||||
const [downloadingAllGallery, setDownloadingAllGallery] = useState(false);
|
const [downloadingAllGallery, setDownloadingAllGallery] = useState(false);
|
||||||
const [activeTab, setActiveTab] = useState<"albums" | "tracks" | "gallery">("albums");
|
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 filteredAlbumGroups = useMemo(() => {
|
||||||
const albumTypeMap = new Map(albumList.map(a => [a.name, a.album_type]));
|
const albumTypeMap = new Map(albumList.map(a => [a.name, a.album_type]));
|
||||||
const albumGroups = trackList.reduce((acc, track) => {
|
const albumGroups = trackList.reduce((acc, track) => {
|
||||||
@@ -125,6 +151,17 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
|
|||||||
return dateB.localeCompare(dateA);
|
return dateB.localeCompare(dateA);
|
||||||
});
|
});
|
||||||
}, [trackList, albumList]);
|
}, [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 () => {
|
const handleDownloadHeader = async () => {
|
||||||
if (!artistInfo.header)
|
if (!artistInfo.header)
|
||||||
return;
|
return;
|
||||||
@@ -317,7 +354,7 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
|
|||||||
<h2 className="text-4xl font-bold text-white">{artistInfo.name}</h2>
|
<h2 className="text-4xl font-bold text-white">{artistInfo.name}</h2>
|
||||||
{artistInfo.verified && (<BadgeCheck className="h-6 w-6 text-white fill-blue-400 shrink-0"/>)}
|
{artistInfo.verified && (<BadgeCheck className="h-6 w-6 text-white fill-blue-400 shrink-0"/>)}
|
||||||
</div>
|
</div>
|
||||||
{artistInfo.biography && (<p className="text-sm text-white/90">{artistInfo.biography}</p>)}
|
{artistInfo.biography && (<p className="text-sm text-white/90 line-clamp-4">{artistInfo.biography}</p>)}
|
||||||
<div className="flex items-center gap-2 text-sm flex-wrap text-white/90">
|
<div className="flex items-center gap-2 text-sm flex-wrap text-white/90">
|
||||||
{artistInfo.rank && (<>
|
{artistInfo.rank && (<>
|
||||||
<span>#{artistInfo.rank} rank</span>
|
<span>#{artistInfo.rank} rank</span>
|
||||||
@@ -330,9 +367,9 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
|
|||||||
</>)}
|
</>)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-sm flex-wrap text-white/90">
|
<div className="flex items-center gap-2 text-sm flex-wrap text-white/90">
|
||||||
<span>{albumList.length} {albumList.length === 1 ? "album" : "albums"}</span>
|
<span>{displayedAlbumCount.toLocaleString()} {displayedAlbumCount === 1 ? "album" : "albums"}</span>
|
||||||
<span>•</span>
|
<span>•</span>
|
||||||
<span>{trackList.length} {trackList.length === 1 ? "track" : "tracks"}</span>
|
<span>{trackList.length.toLocaleString()} {trackList.length === 1 ? "track" : "tracks"}</span>
|
||||||
{artistInfo.genres.length > 0 && (<>
|
{artistInfo.genres.length > 0 && (<>
|
||||||
<span>•</span>
|
<span>•</span>
|
||||||
<span>{artistInfo.genres.join(", ")}</span>
|
<span>{artistInfo.genres.join(", ")}</span>
|
||||||
@@ -370,7 +407,7 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
|
|||||||
<h2 className="text-4xl font-bold">{artistInfo.name}</h2>
|
<h2 className="text-4xl font-bold">{artistInfo.name}</h2>
|
||||||
{artistInfo.verified && (<BadgeCheck className="h-6 w-6 text-white fill-blue-500 shrink-0"/>)}
|
{artistInfo.verified && (<BadgeCheck className="h-6 w-6 text-white fill-blue-500 shrink-0"/>)}
|
||||||
</div>
|
</div>
|
||||||
{artistInfo.biography && (<p className="text-sm text-muted-foreground">{artistInfo.biography}</p>)}
|
{artistInfo.biography && (<p className="text-sm text-muted-foreground line-clamp-4">{artistInfo.biography}</p>)}
|
||||||
<div className="flex items-center gap-2 text-sm flex-wrap">
|
<div className="flex items-center gap-2 text-sm flex-wrap">
|
||||||
{artistInfo.rank && (<>
|
{artistInfo.rank && (<>
|
||||||
<span>#{artistInfo.rank} rank</span>
|
<span>#{artistInfo.rank} rank</span>
|
||||||
@@ -383,9 +420,9 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
|
|||||||
</>)}
|
</>)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-sm flex-wrap">
|
<div className="flex items-center gap-2 text-sm flex-wrap">
|
||||||
<span>{albumList.length} {albumList.length === 1 ? "album" : "albums"}</span>
|
<span>{displayedAlbumCount.toLocaleString()} {displayedAlbumCount === 1 ? "album" : "albums"}</span>
|
||||||
<span>•</span>
|
<span>•</span>
|
||||||
<span>{trackList.length} {trackList.length === 1 ? "track" : "tracks"}</span>
|
<span>{trackList.length.toLocaleString()} {trackList.length === 1 ? "track" : "tracks"}</span>
|
||||||
{artistInfo.genres.length > 0 && (<>
|
{artistInfo.genres.length > 0 && (<>
|
||||||
<span>•</span>
|
<span>•</span>
|
||||||
<span>{artistInfo.genres.join(", ")}</span>
|
<span>{artistInfo.genres.join(", ")}</span>
|
||||||
@@ -412,7 +449,7 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
|
|||||||
|
|
||||||
{activeTab === "gallery" && hasGallery && (<div className="space-y-4">
|
{activeTab === "gallery" && hasGallery && (<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-2xl font-bold">Gallery ({artistInfo.gallery!.length})</h3>
|
<h3 className="text-2xl font-bold">Gallery ({artistInfo.gallery!.length.toLocaleString()})</h3>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button onClick={handleDownloadAllGallery} size="sm" variant="outline" disabled={downloadingAllGallery}>
|
<Button onClick={handleDownloadAllGallery} size="sm" variant="outline" disabled={downloadingAllGallery}>
|
||||||
@@ -446,14 +483,40 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
|
|||||||
</div>)}
|
</div>)}
|
||||||
|
|
||||||
{activeTab === "albums" && albumList.length > 0 && (<div className="space-y-4">
|
{activeTab === "albums" && albumList.length > 0 && (<div className="space-y-4">
|
||||||
<h3 className="text-2xl font-bold">Discography</h3>
|
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||||
|
<h3 className="text-2xl font-bold">Discography</h3>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button onClick={onDownloadAll} size="sm" disabled={isDownloading}>
|
||||||
|
{isDownloading && bulkDownloadType === "all" ? (<Spinner />) : (<Download className="h-4 w-4"/>)}
|
||||||
|
Download Discography
|
||||||
|
</Button>
|
||||||
|
{selectedTracks.length > 0 && (<Button onClick={onDownloadSelected} size="sm" variant="secondary" disabled={isDownloading}>
|
||||||
|
{isDownloading && bulkDownloadType === "selected" ? (<Spinner />) : (<Download className="h-4 w-4"/>)}
|
||||||
|
Download Selected ({selectedTracks.length})
|
||||||
|
</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">
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||||
{albumList.map((album) => (<div key={album.id} className="group cursor-pointer" onClick={() => onAlbumClick({
|
{filteredAlbums.map((album) => {
|
||||||
id: album.id,
|
const albumTracks = trackList.filter(t => t.album_name === album.name);
|
||||||
name: album.name,
|
const tracksWithId = albumTracks.filter(t => t.spotify_id);
|
||||||
external_urls: album.external_urls,
|
const isSelected = tracksWithId.length > 0 && tracksWithId.every(t => selectedTracks.includes(t.spotify_id!));
|
||||||
})}>
|
const hasTracks = tracksWithId.length > 0;
|
||||||
|
return (<div key={album.id} className="group cursor-pointer relative" onClick={() => onAlbumClick({
|
||||||
|
id: album.id,
|
||||||
|
name: album.name,
|
||||||
|
external_urls: album.external_urls,
|
||||||
|
})}>
|
||||||
<div className="relative mb-2">
|
<div className="relative mb-2">
|
||||||
|
|
||||||
|
{hasTracks && (<div className={`absolute top-2 left-2 z-20 ${isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'} transition-opacity`} onClick={(e) => e.stopPropagation()}>
|
||||||
|
<Checkbox checked={isSelected} onCheckedChange={() => onToggleSelectAll(albumTracks)} className="bg-black/50 border-white/70 data-[state=checked]:bg-primary data-[state=checked]:border-primary"/>
|
||||||
|
</div>)}
|
||||||
{album.images && (<img src={album.images} alt={album.name} className="w-full aspect-square object-cover rounded-md shadow-md transition-shadow group-hover:shadow-xl"/>)}
|
{album.images && (<img src={album.images} alt={album.name} className="w-full aspect-square object-cover rounded-md shadow-md transition-shadow group-hover:shadow-xl"/>)}
|
||||||
<div className="absolute bottom-2 right-2">
|
<div className="absolute bottom-2 right-2">
|
||||||
<span className="text-[10px] uppercase font-bold px-1.5 py-0.5 rounded bg-black/60 text-white backdrop-blur-[2px]">
|
<span className="text-[10px] uppercase font-bold px-1.5 py-0.5 rounded bg-black/60 text-white backdrop-blur-[2px]">
|
||||||
@@ -469,8 +532,12 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
|
|||||||
<span>{album.total_tracks} {album.total_tracks === 1 ? "track" : "tracks"}</span>
|
<span>{album.total_tracks} {album.total_tracks === 1 ? "track" : "tracks"}</span>
|
||||||
</>)}
|
</>)}
|
||||||
</div>
|
</div>
|
||||||
</div>))}
|
</div>);
|
||||||
|
})}
|
||||||
</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>)}
|
</div>)}
|
||||||
|
|
||||||
{activeTab === "tracks" && trackList.length > 0 && (<div className="space-y-4">
|
{activeTab === "tracks" && trackList.length > 0 && (<div className="space-y-4">
|
||||||
@@ -491,8 +558,8 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
|
|||||||
<ScrollArea className="flex-1 pr-4">
|
<ScrollArea className="flex-1 pr-4">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{filteredAlbumGroups.map(([albumName, data]) => {
|
{filteredAlbumGroups.map(([albumName, data]) => {
|
||||||
const tracksWithIsrc = data.tracks.filter(t => t.isrc);
|
const tracksWithId = data.tracks.filter(t => t.spotify_id);
|
||||||
const isSelected = tracksWithIsrc.length > 0 && tracksWithIsrc.every(t => selectedTracks.includes(t.isrc!));
|
const isSelected = tracksWithId.length > 0 && tracksWithId.every(t => selectedTracks.includes(t.spotify_id!));
|
||||||
return (<div key={albumName} className="flex items-start space-x-3 p-2 hover:bg-muted/50 rounded-md transition-colors">
|
return (<div key={albumName} className="flex items-start space-x-3 p-2 hover:bg-muted/50 rounded-md transition-colors">
|
||||||
<Checkbox id={`album-select-${albumName}`} checked={isSelected} onCheckedChange={() => onToggleSelectAll(data.tracks)} className="mt-1"/>
|
<Checkbox id={`album-select-${albumName}`} checked={isSelected} onCheckedChange={() => onToggleSelectAll(data.tracks)} className="mt-1"/>
|
||||||
<div className="grid gap-1.5 leading-none flex-1">
|
<div className="grid gap-1.5 leading-none flex-1">
|
||||||
@@ -540,7 +607,7 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
|
|||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>Download All Covers</p>
|
<p>Download All Separate Covers</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>)}
|
</Tooltip>)}
|
||||||
{downloadedTracks.size > 0 && (<Button onClick={onOpenFolder} size="sm" variant="outline">
|
{downloadedTracks.size > 0 && (<Button onClick={onOpenFolder} size="sm" variant="outline">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Activity, Waves, Radio, TrendingUp, FileAudio, Clock, Gauge, HardDrive } from "lucide-react";
|
import { Activity } from "lucide-react";
|
||||||
import type { AnalysisResult } from "@/types/api";
|
import type { AnalysisResult } from "@/types/api";
|
||||||
interface AudioAnalysisProps {
|
interface AudioAnalysisProps {
|
||||||
result: AnalysisResult | null;
|
result: AnalysisResult | null;
|
||||||
@@ -13,32 +13,32 @@ interface AudioAnalysisProps {
|
|||||||
export function AudioAnalysis({ result, analyzing, onAnalyze, showAnalyzeButton = true, filePath }: AudioAnalysisProps) {
|
export function AudioAnalysis({ result, analyzing, onAnalyze, showAnalyzeButton = true, filePath }: AudioAnalysisProps) {
|
||||||
if (analyzing) {
|
if (analyzing) {
|
||||||
return (<Card>
|
return (<Card>
|
||||||
<CardContent className="px-6">
|
<CardContent className="px-6">
|
||||||
<div className="flex items-center justify-center py-8 gap-3">
|
<div className="flex items-center justify-center py-8 gap-3">
|
||||||
<Spinner />
|
<Spinner />
|
||||||
<span className="text-muted-foreground">Analyzing audio quality...</span>
|
<span className="text-muted-foreground">Analyzing audio quality...</span>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>);
|
</Card>);
|
||||||
}
|
}
|
||||||
if (!result && showAnalyzeButton) {
|
if (!result && showAnalyzeButton) {
|
||||||
return (<Card>
|
return (<Card>
|
||||||
<CardContent className="px-6">
|
<CardContent className="px-6">
|
||||||
<div className="flex flex-col items-center justify-center py-8 gap-4">
|
<div className="flex flex-col items-center justify-center py-8 gap-4">
|
||||||
<Activity className="h-12 w-12 text-primary"/>
|
<Activity className="h-12 w-12 text-primary"/>
|
||||||
<div className="text-center space-y-2">
|
<div className="text-center space-y-2">
|
||||||
<p className="font-medium">Audio Quality Analysis</p>
|
<p className="font-medium">Audio Quality Analysis</p>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Verify the true lossless quality of downloaded files
|
Inspect spectral content and effective quality of FLAC, MP3, M4A, and AAC files
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{onAnalyze && (<Button onClick={onAnalyze}>
|
{onAnalyze && (<Button onClick={onAnalyze}>
|
||||||
<Activity className="h-4 w-4"/>
|
<Activity className="h-4 w-4"/>
|
||||||
Analyze Audio
|
Analyze Audio
|
||||||
</Button>)}
|
</Button>)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>);
|
</Card>);
|
||||||
}
|
}
|
||||||
if (!result) {
|
if (!result) {
|
||||||
return null;
|
return null;
|
||||||
@@ -46,7 +46,7 @@ export function AudioAnalysis({ result, analyzing, onAnalyze, showAnalyzeButton
|
|||||||
const formatDuration = (seconds: number) => {
|
const formatDuration = (seconds: number) => {
|
||||||
const mins = Math.floor(seconds / 60);
|
const mins = Math.floor(seconds / 60);
|
||||||
const secs = Math.floor(seconds % 60);
|
const secs = Math.floor(seconds % 60);
|
||||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
return `${mins}:${secs.toString().padStart(2, "0")}`;
|
||||||
};
|
};
|
||||||
const formatNumber = (num: number) => {
|
const formatNumber = (num: number) => {
|
||||||
return num.toFixed(2);
|
return num.toFixed(2);
|
||||||
@@ -60,66 +60,120 @@ export function AudioAnalysis({ result, analyzing, onAnalyze, showAnalyzeButton
|
|||||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
|
||||||
};
|
};
|
||||||
const nyquistFreq = result.sample_rate / 2;
|
const nyquistFreq = result.sample_rate / 2;
|
||||||
|
const totalSamplesText = result.total_samples > 0 ? result.total_samples.toLocaleString() : "N/A";
|
||||||
|
const freqResolutionLabel = result.file_type === "MP3" ? "Freq Res.:" : "Freq Resolution:";
|
||||||
|
const hasCodecMeta = result.file_type === "MP3" && (Boolean(result.codec_mode) ||
|
||||||
|
typeof result.bitrate_kbps === "number" ||
|
||||||
|
typeof result.total_frames === "number" ||
|
||||||
|
Boolean(result.codec_version));
|
||||||
return (<Card className="gap-2">
|
return (<Card className="gap-2">
|
||||||
<CardHeader>
|
<CardHeader className="pb-2">
|
||||||
{filePath && (<p className="text-sm font-mono break-all">{filePath}</p>)}
|
{filePath && (<p className="text-sm font-mono break-all text-muted-foreground">{filePath}</p>)}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className="space-y-2">
|
<CardContent>
|
||||||
|
<div className={`grid grid-cols-1 gap-6 md:grid-cols-2 ${hasCodecMeta ? "lg:grid-cols-4" : "lg:grid-cols-3"}`}>
|
||||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-1">
|
<p className="text-xs font-semibold tracking-wider text-muted-foreground uppercase">Format</p>
|
||||||
<Radio className="h-3 w-3 text-muted-foreground"/>
|
<ul className="text-sm space-y-1">
|
||||||
<span className="text-muted-foreground">Sample Rate:</span>
|
{result.file_type && (<li className="flex justify-between">
|
||||||
<span className="font-semibold">{(result.sample_rate / 1000).toFixed(1)} kHz</span>
|
<span className="text-muted-foreground">Type:</span>
|
||||||
</div>
|
<span className="font-medium font-mono">{result.file_type}</span>
|
||||||
<div className="flex items-center gap-1">
|
</li>)}
|
||||||
<FileAudio className="h-3 w-3 text-muted-foreground"/>
|
<li className="flex justify-between">
|
||||||
<span className="text-muted-foreground">Bit Depth:</span>
|
<span className="text-muted-foreground">Sample Rate:</span>
|
||||||
<span className="font-semibold">{result.bit_depth}</span>
|
<span className="font-medium font-mono">{(result.sample_rate / 1000).toFixed(1)} kHz</span>
|
||||||
</div>
|
</li>
|
||||||
<div className="flex items-center gap-1">
|
<li className="flex justify-between">
|
||||||
<Waves className="h-3 w-3 text-muted-foreground"/>
|
<span className="text-muted-foreground">Bit Depth:</span>
|
||||||
<span className="text-muted-foreground">Channels:</span>
|
<span className="font-medium font-mono">{result.bit_depth}</span>
|
||||||
<span className="font-semibold">{result.channels === 2 ? "Stereo" : result.channels === 1 ? "Mono" : `${result.channels}`}</span>
|
</li>
|
||||||
</div>
|
<li className="flex justify-between">
|
||||||
<div className="flex items-center gap-1">
|
<span className="text-muted-foreground">Channels:</span>
|
||||||
<Clock className="h-3 w-3 text-muted-foreground"/>
|
<span className="font-medium font-mono">{result.channels === 2 ? "Stereo" : result.channels === 1 ? "Mono" : `${result.channels}`}</span>
|
||||||
<span className="text-muted-foreground">Duration:</span>
|
</li>
|
||||||
<span className="font-semibold">{formatDuration(result.duration)}</span>
|
<li className="flex justify-between">
|
||||||
</div>
|
<span className="text-muted-foreground">Duration:</span>
|
||||||
<div className="flex items-center gap-1">
|
<span className="font-medium font-mono">{formatDuration(result.duration)}</span>
|
||||||
<Gauge className="h-3 w-3 text-muted-foreground"/>
|
</li>
|
||||||
<span className="text-muted-foreground">Nyquist:</span>
|
{result.file_size > 0 && (<li className="flex justify-between">
|
||||||
<span className="font-semibold">{(nyquistFreq / 1000).toFixed(1)} kHz</span>
|
<span className="text-muted-foreground">Size:</span>
|
||||||
</div>
|
<span className="font-medium font-mono">{formatFileSize(result.file_size)}</span>
|
||||||
{result.file_size > 0 && (<div className="flex items-center gap-1">
|
</li>)}
|
||||||
<HardDrive className="h-3 w-3 text-muted-foreground"/>
|
</ul>
|
||||||
<span className="text-muted-foreground">Size:</span>
|
</div>
|
||||||
<span className="font-semibold">{formatFileSize(result.file_size)}</span>
|
|
||||||
</div>)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs border-t pt-2">
|
<p className="text-xs font-semibold tracking-wider text-muted-foreground uppercase">Signal Analytics</p>
|
||||||
<div className="flex items-center gap-1">
|
<ul className="text-sm space-y-1">
|
||||||
<TrendingUp className="h-3 w-3 text-muted-foreground"/>
|
<li className="flex justify-between">
|
||||||
<span className="text-muted-foreground">Dynamic Range:</span>
|
<span className="text-muted-foreground">Nyquist:</span>
|
||||||
<span className="font-semibold">{formatNumber(result.dynamic_range)} dB</span>
|
<span className="font-medium font-mono">{(nyquistFreq / 1000).toFixed(1)} kHz</span>
|
||||||
</div>
|
</li>
|
||||||
<div className="flex items-center gap-1">
|
<li className="flex justify-between">
|
||||||
<span className="text-muted-foreground">Peak:</span>
|
<span className="text-muted-foreground">Dynamic Range:</span>
|
||||||
<span className="font-semibold">{formatNumber(result.peak_amplitude)} dB</span>
|
<span className="font-medium font-mono">{formatNumber(result.dynamic_range)} dB</span>
|
||||||
</div>
|
</li>
|
||||||
<div className="flex items-center gap-1">
|
<li className="flex justify-between">
|
||||||
<span className="text-muted-foreground">RMS:</span>
|
<span className="text-muted-foreground">Peak Amplitude:</span>
|
||||||
<span className="font-semibold">{formatNumber(result.rms_level)} dB</span>
|
<span className="font-medium font-mono">{formatNumber(result.peak_amplitude)} dB</span>
|
||||||
</div>
|
</li>
|
||||||
<div className="flex items-center gap-1 ml-auto">
|
<li className="flex justify-between">
|
||||||
<span className="text-muted-foreground">Samples:</span>
|
<span className="text-muted-foreground">RMS Level:</span>
|
||||||
<span className="font-semibold">{result.total_samples.toLocaleString()}</span>
|
<span className="font-medium font-mono">{formatNumber(result.rms_level)} dB</span>
|
||||||
</div>
|
</li>
|
||||||
</div>
|
<li className="flex justify-between">
|
||||||
</CardContent>
|
<span className="text-muted-foreground">Total Samples:</span>
|
||||||
</Card>);
|
<span className="font-medium font-mono">{totalSamplesText}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasCodecMeta && (<div className="space-y-2">
|
||||||
|
<p className="text-xs font-semibold tracking-wider text-muted-foreground uppercase">MP3 Meta</p>
|
||||||
|
<ul className="text-sm space-y-1">
|
||||||
|
{result.codec_mode && (<li className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Mode:</span>
|
||||||
|
<span className="font-medium font-mono">{result.codec_mode}</span>
|
||||||
|
</li>)}
|
||||||
|
{typeof result.bitrate_kbps === "number" && (<li className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Bitrate:</span>
|
||||||
|
<span className="font-medium font-mono">{result.bitrate_kbps} kbps</span>
|
||||||
|
</li>)}
|
||||||
|
{typeof result.total_frames === "number" && result.total_frames > 0 && (<li className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Frames:</span>
|
||||||
|
<span className="font-medium font-mono">{result.total_frames.toLocaleString()}</span>
|
||||||
|
</li>)}
|
||||||
|
{result.codec_version && (<li className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Version:</span>
|
||||||
|
<span className="font-medium font-mono">{result.codec_version}</span>
|
||||||
|
</li>)}
|
||||||
|
</ul>
|
||||||
|
</div>)}
|
||||||
|
|
||||||
|
{result.spectrum && (() => {
|
||||||
|
const frames = result.spectrum.time_slices.length;
|
||||||
|
const fftSize = (result.spectrum.freq_bins - 1) * 2;
|
||||||
|
const freqRes = result.sample_rate / fftSize;
|
||||||
|
return (<div className="space-y-2">
|
||||||
|
<p className="text-xs font-semibold tracking-wider text-muted-foreground uppercase">Spectrum Meta</p>
|
||||||
|
<ul className="text-sm space-y-1">
|
||||||
|
<li className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Display Frames:</span>
|
||||||
|
<span className="font-medium font-mono">{frames.toLocaleString()}</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">FFT Size:</span>
|
||||||
|
<span className="font-medium font-mono">{fftSize.toLocaleString()}</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">{freqResolutionLabel}</span>
|
||||||
|
<span className="font-medium font-mono">{freqRes.toFixed(2)} Hz/bin</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,72 +1,187 @@
|
|||||||
import { useState, useCallback, useEffect } from "react";
|
import { useState, useCallback, useRef, useEffect, type ChangeEvent, type DragEvent, type CSSProperties } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Upload, ArrowLeft, Trash2 } from "lucide-react";
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
import { Upload, ArrowLeft, Trash2, Download } from "lucide-react";
|
||||||
import { AudioAnalysis } from "@/components/AudioAnalysis";
|
import { AudioAnalysis } from "@/components/AudioAnalysis";
|
||||||
import { SpectrumVisualization } from "@/components/SpectrumVisualization";
|
import { SpectrumVisualization } from "@/components/SpectrumVisualization";
|
||||||
import { useAudioAnalysis } from "@/hooks/useAudioAnalysis";
|
import { useAudioAnalysis } from "@/hooks/useAudioAnalysis";
|
||||||
import { SelectFile } from "../../wailsjs/go/main/App";
|
|
||||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||||
|
import { SelectFile, SaveSpectrumImage } from "../../wailsjs/go/main/App";
|
||||||
import { OnFileDrop, OnFileDropOff } from "../../wailsjs/runtime/runtime";
|
import { OnFileDrop, OnFileDropOff } from "../../wailsjs/runtime/runtime";
|
||||||
interface AudioAnalysisPageProps {
|
interface AudioAnalysisPageProps {
|
||||||
onBack?: () => void;
|
onBack?: () => void;
|
||||||
}
|
}
|
||||||
|
const SUPPORTED_AUDIO_EXTENSIONS = [".flac", ".mp3", ".m4a", ".aac"];
|
||||||
|
const SUPPORTED_AUDIO_ACCEPT = [
|
||||||
|
".flac",
|
||||||
|
".mp3",
|
||||||
|
".m4a",
|
||||||
|
".aac",
|
||||||
|
"audio/flac",
|
||||||
|
"audio/x-flac",
|
||||||
|
"audio/mpeg",
|
||||||
|
"audio/mp3",
|
||||||
|
"audio/mp4",
|
||||||
|
"audio/x-m4a",
|
||||||
|
"audio/aac",
|
||||||
|
"audio/aacp",
|
||||||
|
].join(",");
|
||||||
|
const SUPPORTED_AUDIO_LABEL = "FLAC, MP3, M4A, or AAC";
|
||||||
|
function isSupportedAudioPath(filePath: string): boolean {
|
||||||
|
const normalized = filePath.toLowerCase();
|
||||||
|
return SUPPORTED_AUDIO_EXTENSIONS.some((ext) => normalized.endsWith(ext));
|
||||||
|
}
|
||||||
|
function isSupportedAudioFile(file: File): boolean {
|
||||||
|
const normalizedName = file.name.toLowerCase();
|
||||||
|
const normalizedType = file.type.toLowerCase();
|
||||||
|
return (SUPPORTED_AUDIO_EXTENSIONS.some((ext) => normalizedName.endsWith(ext)) ||
|
||||||
|
normalizedType === "audio/flac" ||
|
||||||
|
normalizedType === "audio/x-flac" ||
|
||||||
|
normalizedType === "audio/mpeg" ||
|
||||||
|
normalizedType === "audio/mp3" ||
|
||||||
|
normalizedType === "audio/mp4" ||
|
||||||
|
normalizedType === "audio/x-m4a" ||
|
||||||
|
normalizedType === "audio/aac" ||
|
||||||
|
normalizedType === "audio/aacp");
|
||||||
|
}
|
||||||
|
function isAbsolutePath(filePath: string): boolean {
|
||||||
|
return /^(?:[a-zA-Z]:[\\/]|\\\\|\/)/.test(filePath);
|
||||||
|
}
|
||||||
|
function fileNameFromPath(filePath: string): string {
|
||||||
|
const parts = filePath.split(/[/\\]/);
|
||||||
|
return parts[parts.length - 1] || filePath;
|
||||||
|
}
|
||||||
export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
|
export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
|
||||||
const { analyzing, result, analyzeFile, clearResult, selectedFilePath, spectrumLoading } = useAudioAnalysis();
|
const { analyzing, analysisProgress, result, analyzeFile, analyzeFilePath, clearResult, selectedFilePath, spectrumLoading, spectrumProgress, reAnalyzeSpectrum, } = useAudioAnalysis();
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
const handleSelectFile = async () => {
|
const [isExporting, setIsExporting] = useState(false);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const spectrumRef = useRef<{
|
||||||
|
getCanvasDataURL: () => string | null;
|
||||||
|
}>(null);
|
||||||
|
const analyzeSelectedPath = useCallback(async (filePath: string) => {
|
||||||
|
if (!isSupportedAudioPath(filePath)) {
|
||||||
|
toast.error("Invalid File Type", {
|
||||||
|
description: `Please select a ${SUPPORTED_AUDIO_LABEL} file for analysis`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await analyzeFilePath(filePath);
|
||||||
|
}, [analyzeFilePath]);
|
||||||
|
const analyzeSelectedFile = useCallback(async (file: File) => {
|
||||||
|
if (!isSupportedAudioFile(file)) {
|
||||||
|
toast.error("Invalid File Type", {
|
||||||
|
description: `Please select a ${SUPPORTED_AUDIO_LABEL} file for analysis`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await analyzeFile(file);
|
||||||
|
}, [analyzeFile]);
|
||||||
|
const handleSelectFile = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const filePath = await SelectFile();
|
const filePath = await SelectFile();
|
||||||
if (filePath) {
|
if (!filePath) {
|
||||||
await analyzeFile(filePath);
|
return;
|
||||||
}
|
}
|
||||||
|
await analyzeSelectedPath(filePath);
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch {
|
||||||
toast.error("File Selection Failed", {
|
fileInputRef.current?.click();
|
||||||
description: err instanceof Error ? err.message : "Failed to select file",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
}, [analyzeSelectedPath]);
|
||||||
const handleFileDrop = useCallback(async (_x: number, _y: number, paths: string[]) => {
|
const handleInputChange = useCallback(async (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file)
|
||||||
|
return;
|
||||||
|
await analyzeSelectedFile(file);
|
||||||
|
e.target.value = "";
|
||||||
|
}, [analyzeSelectedFile]);
|
||||||
|
const handleHtmlDrop = useCallback(async (e: DragEvent<HTMLDivElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
if (paths.length === 0)
|
const file = e.dataTransfer.files?.[0];
|
||||||
|
if (!file)
|
||||||
return;
|
return;
|
||||||
const filePath = paths[0];
|
await analyzeSelectedFile(file);
|
||||||
if (!filePath.toLowerCase().endsWith(".flac")) {
|
}, [analyzeSelectedFile]);
|
||||||
toast.error("Invalid File Type", {
|
|
||||||
description: "Please drop a FLAC file for analysis",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await analyzeFile(filePath);
|
|
||||||
}, [analyzeFile]);
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
OnFileDrop((x, y, paths) => {
|
OnFileDrop((_x, _y, paths) => {
|
||||||
handleFileDrop(x, y, paths);
|
setIsDragging(false);
|
||||||
|
const droppedPath = paths?.[0];
|
||||||
|
if (!droppedPath)
|
||||||
|
return;
|
||||||
|
void analyzeSelectedPath(droppedPath);
|
||||||
}, true);
|
}, true);
|
||||||
return () => {
|
return () => {
|
||||||
OnFileDropOff();
|
OnFileDropOff();
|
||||||
};
|
};
|
||||||
}, [handleFileDrop]);
|
}, [analyzeSelectedPath]);
|
||||||
|
const handleExport = useCallback(async () => {
|
||||||
|
if (!spectrumRef.current)
|
||||||
|
return;
|
||||||
|
const dataUrl = spectrumRef.current.getCanvasDataURL();
|
||||||
|
if (!dataUrl) {
|
||||||
|
toast.error("Export Failed", { description: "Cannot get canvas data" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsExporting(true);
|
||||||
|
try {
|
||||||
|
if (selectedFilePath && isAbsolutePath(selectedFilePath)) {
|
||||||
|
const outPath = await SaveSpectrumImage(selectedFilePath, dataUrl);
|
||||||
|
toast.success("Exported Successfully", {
|
||||||
|
description: `Saved to: ${outPath}`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const base = selectedFilePath
|
||||||
|
? fileNameFromPath(selectedFilePath).replace(/\.[^/.]+$/, "")
|
||||||
|
: "spectrogram";
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = dataUrl;
|
||||||
|
a.download = `${base}_spectrogram.png`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
toast.success("Exported Successfully", {
|
||||||
|
description: "Spectrogram image downloaded",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
toast.error("Export Failed", {
|
||||||
|
description: err instanceof Error ? err.message : "Failed to export image",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
setIsExporting(false);
|
||||||
|
}
|
||||||
|
}, [selectedFilePath]);
|
||||||
const handleAnalyzeAnother = () => {
|
const handleAnalyzeAnother = () => {
|
||||||
clearResult();
|
clearResult();
|
||||||
};
|
};
|
||||||
|
const fileName = selectedFilePath ? fileNameFromPath(selectedFilePath) : undefined;
|
||||||
return (<div className="space-y-6">
|
return (<div className="space-y-6">
|
||||||
|
<input ref={fileInputRef} type="file" accept={SUPPORTED_AUDIO_ACCEPT} className="hidden" onChange={handleInputChange}/>
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
{onBack && (<Button variant="ghost" size="icon" onClick={onBack}>
|
|
||||||
<ArrowLeft className="h-5 w-5"/>
|
|
||||||
</Button>)}
|
|
||||||
<h1 className="text-2xl font-bold">Audio Quality Analyzer</h1>
|
|
||||||
</div>
|
|
||||||
{result && (<Button onClick={handleAnalyzeAnother} variant="outline" size="sm">
|
|
||||||
<Trash2 className="h-4 w-4"/>
|
|
||||||
Clear
|
|
||||||
</Button>)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
{!result && !analyzing && (<div className={`flex flex-col items-center justify-center h-[400px] border-2 border-dashed rounded-lg transition-colors ${isDragging
|
<div className="flex items-center gap-4">
|
||||||
|
{onBack && (<Button variant="ghost" size="icon" onClick={onBack}>
|
||||||
|
<ArrowLeft className="h-5 w-5"/>
|
||||||
|
</Button>)}
|
||||||
|
<h1 className="text-2xl font-bold">Audio Quality Analyzer</h1>
|
||||||
|
</div>
|
||||||
|
{result && (<div className="flex gap-2">
|
||||||
|
<Button onClick={handleExport} variant="outline" size="sm" disabled={isExporting || spectrumLoading}>
|
||||||
|
<Download className="h-4 w-4 mr-1"/>
|
||||||
|
{isExporting ? "Exporting..." : "Export PNG"}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleAnalyzeAnother} variant="outline" size="sm">
|
||||||
|
<Trash2 className="h-4 w-4 mr-1"/>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
</div>)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!result && !analyzing && (<div className={`flex flex-col items-center justify-center h-[400px] border-2 border-dashed rounded-lg transition-colors ${isDragging
|
||||||
? "border-primary bg-primary/10"
|
? "border-primary bg-primary/10"
|
||||||
: "border-muted-foreground/30"}`} onDragOver={(e) => {
|
: "border-muted-foreground/30"}`} onDragOver={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -74,40 +189,38 @@ export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
|
|||||||
}} onDragLeave={(e) => {
|
}} onDragLeave={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
}} onDrop={(e) => {
|
}} onDrop={handleHtmlDrop} style={{ "--wails-drop-target": "drop" } as CSSProperties}>
|
||||||
e.preventDefault();
|
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted">
|
||||||
setIsDragging(false);
|
<Upload className="h-8 w-8 text-primary"/>
|
||||||
}} style={{ "--wails-drop-target": "drop" } as React.CSSProperties}>
|
</div>
|
||||||
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted">
|
<p className="text-sm text-muted-foreground mb-4 text-center">
|
||||||
<Upload className="h-8 w-8 text-primary"/>
|
{isDragging
|
||||||
</div>
|
? "Drop your audio file here"
|
||||||
<p className="text-sm text-muted-foreground mb-4 text-center">
|
: "Drag and drop an audio file here, or click the button below to select"}
|
||||||
{isDragging
|
</p>
|
||||||
? "Drop your FLAC file here"
|
<Button onClick={handleSelectFile} size="lg">
|
||||||
: "Drag and drop a FLAC file here, or click the button below to select"}
|
<Upload className="h-5 w-5"/>
|
||||||
</p>
|
Select Audio File
|
||||||
<Button onClick={handleSelectFile} size="lg">
|
</Button>
|
||||||
<Upload className="h-5 w-5"/>
|
<p className="text-xs text-muted-foreground mt-4 text-center">
|
||||||
Select FLAC File
|
Supported formats: FLAC, MP3, M4A, AAC
|
||||||
</Button>
|
</p>
|
||||||
</div>)}
|
</div>)}
|
||||||
|
|
||||||
|
{analyzing && !result && (<div className="flex h-[400px] items-center justify-center">
|
||||||
{analyzing && !result && (<div className="flex flex-col items-center justify-center py-16">
|
<div className="w-full max-w-md space-y-2">
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mb-4"></div>
|
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||||
<p className="text-sm text-muted-foreground">Analyzing audio file...</p>
|
<span>Processing...</span>
|
||||||
</div>)}
|
<span className="tabular-nums">{analysisProgress.percent}%</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={analysisProgress.percent} className="h-2 w-full"/>
|
||||||
|
</div>
|
||||||
|
</div>)}
|
||||||
|
|
||||||
|
{result && (<div className="space-y-4">
|
||||||
{result && (<div className="space-y-4">
|
<AudioAnalysis result={result} analyzing={analyzing} showAnalyzeButton={false} filePath={selectedFilePath}/>
|
||||||
|
|
||||||
<AudioAnalysis result={result} analyzing={analyzing} showAnalyzeButton={false} filePath={selectedFilePath}/>
|
|
||||||
|
|
||||||
|
<SpectrumVisualization ref={spectrumRef} sampleRate={result.sample_rate} duration={result.duration} spectrumData={result.spectrum} fileName={fileName} onReAnalyze={reAnalyzeSpectrum} isAnalyzingSpectrum={spectrumLoading} spectrumProgress={spectrumProgress}/>
|
||||||
{spectrumLoading ? (<div className="flex flex-col items-center justify-center py-16 border rounded-lg">
|
</div>)}
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mb-2"></div>
|
</div>);
|
||||||
<p className="text-sm text-muted-foreground">Loading spectrum data...</p>
|
|
||||||
</div>) : (<SpectrumVisualization sampleRate={result.sample_rate} bitsPerSample={result.bits_per_sample} duration={result.duration} spectrumData={result.spectrum}/>)}
|
|
||||||
</div>)}
|
|
||||||
</div>);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Label } from "@/components/ui/label";
|
|||||||
import { ToggleGroup, ToggleGroupItem, } from "@/components/ui/toggle-group";
|
import { ToggleGroup, ToggleGroupItem, } from "@/components/ui/toggle-group";
|
||||||
import { Upload, X, CheckCircle2, AlertCircle, Trash2, FileMusic, WandSparkles, } from "lucide-react";
|
import { Upload, X, CheckCircle2, AlertCircle, Trash2, FileMusic, WandSparkles, } from "lucide-react";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { ConvertAudio, SelectAudioFiles, } from "../../wailsjs/go/main/App";
|
import { ConvertAudio, SelectAudioFiles, SelectFolder, ListAudioFilesInDir, } from "../../wailsjs/go/main/App";
|
||||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||||
import { OnFileDrop, OnFileDropOff } from "../../wailsjs/runtime/runtime";
|
import { OnFileDrop, OnFileDropOff } from "../../wailsjs/runtime/runtime";
|
||||||
interface AudioFile {
|
interface AudioFile {
|
||||||
@@ -152,6 +152,27 @@ export function AudioConverterPage() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const handleSelectFolder = async () => {
|
||||||
|
try {
|
||||||
|
const selectedFolder = await SelectFolder("");
|
||||||
|
if (selectedFolder) {
|
||||||
|
const folderFiles = await ListAudioFilesInDir(selectedFolder);
|
||||||
|
if (folderFiles && folderFiles.length > 0) {
|
||||||
|
addFiles(folderFiles.map((f) => f.path));
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
toast.info("No audio files found", {
|
||||||
|
description: "No FLAC or MP3 files found in the selected folder.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
toast.error("Folder Selection Failed", {
|
||||||
|
description: err instanceof Error ? err.message : "Failed to select folder",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
const addFiles = useCallback(async (paths: string[]) => {
|
const addFiles = useCallback(async (paths: string[]) => {
|
||||||
const validExtensions = [".mp3", ".flac"];
|
const validExtensions = [".mp3", ".flac"];
|
||||||
const m4aFiles = paths.filter((path) => {
|
const m4aFiles = paths.filter((path) => {
|
||||||
@@ -298,7 +319,11 @@ export function AudioConverterPage() {
|
|||||||
{files.length > 0 && (<div className="flex gap-2">
|
{files.length > 0 && (<div className="flex gap-2">
|
||||||
<Button variant="outline" size="sm" onClick={handleSelectFiles}>
|
<Button variant="outline" size="sm" onClick={handleSelectFiles}>
|
||||||
<Upload className="h-4 w-4"/>
|
<Upload className="h-4 w-4"/>
|
||||||
Add More
|
Add Files
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={handleSelectFolder}>
|
||||||
|
<Upload className="h-4 w-4"/>
|
||||||
|
Add Folder
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" size="sm" onClick={clearFiles} disabled={converting}>
|
<Button variant="outline" size="sm" onClick={clearFiles} disabled={converting}>
|
||||||
<Trash2 className="h-4 w-4"/>
|
<Trash2 className="h-4 w-4"/>
|
||||||
@@ -329,10 +354,16 @@ export function AudioConverterPage() {
|
|||||||
? "Drop your audio files here"
|
? "Drop your audio files here"
|
||||||
: "Drag and drop audio files here, or click the button below to select"}
|
: "Drag and drop audio files here, or click the button below to select"}
|
||||||
</p>
|
</p>
|
||||||
<Button onClick={handleSelectFiles} size="lg">
|
<div className="flex gap-3">
|
||||||
<Upload className="h-5 w-5"/>
|
<Button onClick={handleSelectFiles} size="lg">
|
||||||
Select Files
|
<Upload className="h-5 w-5"/>
|
||||||
</Button>
|
Select Files
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSelectFolder} size="lg" variant="outline">
|
||||||
|
<Upload className="h-5 w-5"/>
|
||||||
|
Select Folder
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<p className="text-xs text-muted-foreground mt-4 text-center">
|
<p className="text-xs text-muted-foreground mt-4 text-center">
|
||||||
Supported formats: FLAC, MP3
|
Supported formats: FLAC, MP3
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -0,0 +1,468 @@
|
|||||||
|
import { useState, useCallback, useEffect } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||||
|
import { Upload, X, CheckCircle2, AlertCircle, Trash2, FileMusic } from "lucide-react";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
import { SelectAudioFiles, SelectFolder, ListAudioFilesInDir, ResampleAudio } from "../../wailsjs/go/main/App";
|
||||||
|
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||||
|
import { OnFileDrop, OnFileDropOff } from "../../wailsjs/runtime/runtime";
|
||||||
|
import { AudioLinesIcon } from "@/components/ui/audio-lines";
|
||||||
|
interface AudioFile {
|
||||||
|
path: string;
|
||||||
|
name: string;
|
||||||
|
format: string;
|
||||||
|
size: number;
|
||||||
|
status: "pending" | "resampling" | "success" | "error";
|
||||||
|
error?: string;
|
||||||
|
outputPath?: string;
|
||||||
|
srcSampleRate?: number;
|
||||||
|
srcBitDepth?: number;
|
||||||
|
}
|
||||||
|
function formatFileSize(bytes: number): string {
|
||||||
|
if (bytes === 0)
|
||||||
|
return "0 B";
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ["B", "KB", "MB", "GB"];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
|
||||||
|
}
|
||||||
|
function formatSampleRate(sr: number): string {
|
||||||
|
if (!sr)
|
||||||
|
return "";
|
||||||
|
if (sr === 44100)
|
||||||
|
return "44.1kHz";
|
||||||
|
if (sr >= 1000)
|
||||||
|
return `${sr / 1000}kHz`;
|
||||||
|
return `${sr}Hz`;
|
||||||
|
}
|
||||||
|
const SAMPLE_RATE_OPTIONS = [
|
||||||
|
{ value: "44100", label: "44.1kHz" },
|
||||||
|
{ value: "48000", label: "48kHz" },
|
||||||
|
{ value: "96000", label: "96kHz" },
|
||||||
|
{ value: "192000", label: "192kHz" },
|
||||||
|
];
|
||||||
|
const BIT_DEPTH_OPTIONS = [
|
||||||
|
{ value: "16", label: "16-bit" },
|
||||||
|
{ value: "24", label: "24-bit" },
|
||||||
|
];
|
||||||
|
const STORAGE_KEY = "spotiflac_audio_resampler_state";
|
||||||
|
export function AudioResamplerPage() {
|
||||||
|
const [files, setFiles] = useState<AudioFile[]>(() => {
|
||||||
|
try {
|
||||||
|
const saved = sessionStorage.getItem(STORAGE_KEY);
|
||||||
|
if (saved) {
|
||||||
|
const parsed = JSON.parse(saved);
|
||||||
|
if (parsed.files && Array.isArray(parsed.files) && parsed.files.length > 0) {
|
||||||
|
return parsed.files;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.error("Failed to load saved state:", err);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
const [sampleRate, setSampleRate] = useState(() => {
|
||||||
|
try {
|
||||||
|
const saved = sessionStorage.getItem(STORAGE_KEY);
|
||||||
|
if (saved) {
|
||||||
|
const parsed = JSON.parse(saved);
|
||||||
|
if (parsed.sampleRate)
|
||||||
|
return parsed.sampleRate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
}
|
||||||
|
return "44100";
|
||||||
|
});
|
||||||
|
const [bitDepth, setBitDepth] = useState(() => {
|
||||||
|
try {
|
||||||
|
const saved = sessionStorage.getItem(STORAGE_KEY);
|
||||||
|
if (saved) {
|
||||||
|
const parsed = JSON.parse(saved);
|
||||||
|
if (parsed.bitDepth)
|
||||||
|
return parsed.bitDepth;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
}
|
||||||
|
return "16";
|
||||||
|
});
|
||||||
|
const [resampling, setResampling] = useState(false);
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
|
const saveState = useCallback((stateToSave: {
|
||||||
|
files: AudioFile[];
|
||||||
|
sampleRate: string;
|
||||||
|
bitDepth: string;
|
||||||
|
}) => {
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(stateToSave));
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.error("Failed to save state:", err);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
useEffect(() => {
|
||||||
|
saveState({ files, sampleRate, bitDepth });
|
||||||
|
}, [files, sampleRate, bitDepth, saveState]);
|
||||||
|
useEffect(() => {
|
||||||
|
const checkFullscreen = () => {
|
||||||
|
const isMaximized = window.innerHeight >= window.screen.height * 0.9;
|
||||||
|
setIsFullscreen(isMaximized);
|
||||||
|
};
|
||||||
|
checkFullscreen();
|
||||||
|
window.addEventListener("resize", checkFullscreen);
|
||||||
|
window.addEventListener("focus", checkFullscreen);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("resize", checkFullscreen);
|
||||||
|
window.removeEventListener("focus", checkFullscreen);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
const fetchAudioInfo = useCallback(async (paths: string[]) => {
|
||||||
|
if (paths.length === 0)
|
||||||
|
return;
|
||||||
|
try {
|
||||||
|
const GetFlacInfoBatch = (window as any)["go"]["main"]["App"]["GetFlacInfoBatch"];
|
||||||
|
const infos: Array<{
|
||||||
|
path: string;
|
||||||
|
sample_rate: number;
|
||||||
|
bits_per_sample: number;
|
||||||
|
}> = await GetFlacInfoBatch(paths);
|
||||||
|
setFiles((prev) => prev.map((f) => {
|
||||||
|
const info = infos.find((i) => i.path === f.path || i.path.toLowerCase() === f.path.toLowerCase());
|
||||||
|
if (info) {
|
||||||
|
return {
|
||||||
|
...f,
|
||||||
|
srcSampleRate: info.sample_rate || undefined,
|
||||||
|
srcBitDepth: info.bits_per_sample || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return f;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.error("Failed to fetch audio info:", err);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
const handleSelectFiles = async () => {
|
||||||
|
try {
|
||||||
|
const selectedFiles = await SelectAudioFiles();
|
||||||
|
if (selectedFiles && selectedFiles.length > 0) {
|
||||||
|
addFiles(selectedFiles);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
toast.error("File Selection Failed", {
|
||||||
|
description: err instanceof Error ? err.message : "Failed to select files",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleSelectFolder = async () => {
|
||||||
|
try {
|
||||||
|
const selectedFolder = await SelectFolder("");
|
||||||
|
if (selectedFolder) {
|
||||||
|
const folderFiles = await ListAudioFilesInDir(selectedFolder);
|
||||||
|
if (folderFiles && folderFiles.length > 0) {
|
||||||
|
addFiles(folderFiles.map((f) => f.path));
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
toast.info("No audio files found", {
|
||||||
|
description: "No FLAC files found in the selected folder.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
toast.error("Folder Selection Failed", {
|
||||||
|
description: err instanceof Error ? err.message : "Failed to select folder",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const addFiles = useCallback(async (paths: string[]) => {
|
||||||
|
const validExtensions = [".flac"];
|
||||||
|
const invalidFiles = paths.filter((path) => {
|
||||||
|
const ext = path.toLowerCase().slice(path.lastIndexOf("."));
|
||||||
|
return !validExtensions.includes(ext);
|
||||||
|
});
|
||||||
|
if (invalidFiles.length > 0) {
|
||||||
|
toast.error("Unsupported format", {
|
||||||
|
description: "Only FLAC files are supported for resampling.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const GetFileSizes = (files: string[]): Promise<Record<string, number>> => (window as any)["go"]["main"]["App"]["GetFileSizes"](files);
|
||||||
|
const validPaths = paths.filter((path) => {
|
||||||
|
const ext = path.toLowerCase().slice(path.lastIndexOf("."));
|
||||||
|
return validExtensions.includes(ext);
|
||||||
|
});
|
||||||
|
const fileSizes = validPaths.length > 0 ? await GetFileSizes(validPaths) : {};
|
||||||
|
let newlyAddedPaths: string[] = [];
|
||||||
|
setFiles((prev) => {
|
||||||
|
const newFiles: AudioFile[] = validPaths
|
||||||
|
.filter((path) => !prev.some((f) => f.path === path))
|
||||||
|
.map((path) => {
|
||||||
|
const name = path.split(/[/\\]/).pop() || path;
|
||||||
|
const ext = name.slice(name.lastIndexOf(".") + 1).toLowerCase();
|
||||||
|
return {
|
||||||
|
path,
|
||||||
|
name,
|
||||||
|
format: ext,
|
||||||
|
size: fileSizes[path] || 0,
|
||||||
|
status: "pending" as const,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
newlyAddedPaths = newFiles.map((f) => f.path);
|
||||||
|
if (newFiles.length > 0) {
|
||||||
|
if (paths.length > newFiles.length + invalidFiles.length) {
|
||||||
|
const skipped = paths.length - newFiles.length - invalidFiles.length;
|
||||||
|
toast.info("Some files skipped", {
|
||||||
|
description: `${skipped} file(s) were already added`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return [...prev, ...newFiles];
|
||||||
|
}
|
||||||
|
if (validPaths.length > 0 && newFiles.length === 0) {
|
||||||
|
toast.info("No new files added", {
|
||||||
|
description: "All valid files were already added",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
setTimeout(() => {
|
||||||
|
if (newlyAddedPaths.length > 0) {
|
||||||
|
fetchAudioInfo(newlyAddedPaths);
|
||||||
|
}
|
||||||
|
}, 50);
|
||||||
|
}, [fetchAudioInfo]);
|
||||||
|
const handleFileDrop = useCallback(async (_x: number, _y: number, paths: string[]) => {
|
||||||
|
setIsDragging(false);
|
||||||
|
if (paths.length === 0)
|
||||||
|
return;
|
||||||
|
addFiles(paths);
|
||||||
|
}, [addFiles]);
|
||||||
|
useEffect(() => {
|
||||||
|
OnFileDrop((x, y, paths) => {
|
||||||
|
handleFileDrop(x, y, paths);
|
||||||
|
}, true);
|
||||||
|
return () => {
|
||||||
|
OnFileDropOff();
|
||||||
|
};
|
||||||
|
}, [handleFileDrop]);
|
||||||
|
const removeFile = (path: string) => {
|
||||||
|
setFiles((prev) => prev.filter((f) => f.path !== path));
|
||||||
|
};
|
||||||
|
const clearFiles = () => {
|
||||||
|
setFiles([]);
|
||||||
|
};
|
||||||
|
const handleResample = async () => {
|
||||||
|
if (files.length === 0) {
|
||||||
|
toast.error("No files selected", {
|
||||||
|
description: "Please add FLAC files to resample",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setResampling(true);
|
||||||
|
try {
|
||||||
|
const inputPaths = files.map((f) => f.path);
|
||||||
|
setFiles((prev) => prev.map((f) => {
|
||||||
|
if (inputPaths.includes(f.path)) {
|
||||||
|
return { ...f, status: "resampling" as const, error: undefined };
|
||||||
|
}
|
||||||
|
return f;
|
||||||
|
}));
|
||||||
|
const results = await ResampleAudio({
|
||||||
|
input_files: inputPaths,
|
||||||
|
sample_rate: sampleRate,
|
||||||
|
bit_depth: bitDepth,
|
||||||
|
});
|
||||||
|
setFiles((prev) => prev.map((f) => {
|
||||||
|
const result = results.find((r: any) => r.input_file === f.path || r.input_file.toLowerCase() === f.path.toLowerCase());
|
||||||
|
if (result) {
|
||||||
|
return {
|
||||||
|
...f,
|
||||||
|
status: result.success ? "success" : "error",
|
||||||
|
error: result.error,
|
||||||
|
outputPath: result.output_file,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return f;
|
||||||
|
}));
|
||||||
|
const successCount = results.filter((r: any) => r.success).length;
|
||||||
|
const failCount = results.filter((r: any) => !r.success).length;
|
||||||
|
if (successCount > 0) {
|
||||||
|
toast.success("Resampling Complete", {
|
||||||
|
description: `Successfully resampled ${successCount} file(s)${failCount > 0 ? `, ${failCount} failed` : ""}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else if (failCount > 0) {
|
||||||
|
toast.error("Resampling Failed", {
|
||||||
|
description: `All ${failCount} file(s) failed to resample`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
toast.error("Resampling Error", {
|
||||||
|
description: err instanceof Error ? err.message : "Unknown error",
|
||||||
|
});
|
||||||
|
setFiles((prev) => prev.map((f) => ({ ...f, status: "error" as const, error: "Resampling failed" })));
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
setResampling(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const getStatusIcon = (status: AudioFile["status"]) => {
|
||||||
|
switch (status) {
|
||||||
|
case "resampling":
|
||||||
|
return <Spinner className="h-4 w-4 text-primary"/>;
|
||||||
|
case "success":
|
||||||
|
return <CheckCircle2 className="h-4 w-4 text-green-500"/>;
|
||||||
|
case "error":
|
||||||
|
return <AlertCircle className="h-4 w-4 text-destructive"/>;
|
||||||
|
default:
|
||||||
|
return <FileMusic className="h-4 w-4 text-muted-foreground"/>;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const resampleableCount = files.filter((f) => f.status === "pending" || f.status === "success").length;
|
||||||
|
const successCount = files.filter((f) => f.status === "success").length;
|
||||||
|
return (<div className={`space-y-6 ${isFullscreen ? "h-full flex flex-col" : ""}`}>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-bold">Audio Resampler</h1>
|
||||||
|
{files.length > 0 && (<div className="flex gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={handleSelectFiles}>
|
||||||
|
<Upload className="h-4 w-4"/>
|
||||||
|
Add Files
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={handleSelectFolder}>
|
||||||
|
<Upload className="h-4 w-4"/>
|
||||||
|
Add Folder
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={clearFiles} disabled={resampling}>
|
||||||
|
<Trash2 className="h-4 w-4"/>
|
||||||
|
Clear All
|
||||||
|
</Button>
|
||||||
|
</div>)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`flex flex-col items-center justify-center border-2 border-dashed rounded-lg transition-all ${isFullscreen ? "flex-1 min-h-[400px]" : "h-[400px]"} ${isDragging
|
||||||
|
? "border-primary bg-primary/10"
|
||||||
|
: "border-muted-foreground/30"}`} onDragOver={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(true);
|
||||||
|
}} onDragLeave={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(false);
|
||||||
|
}} onDrop={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(false);
|
||||||
|
}} style={{ "--wails-drop-target": "drop" } as React.CSSProperties}>
|
||||||
|
{files.length === 0 ? (<>
|
||||||
|
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted">
|
||||||
|
<Upload className="h-8 w-8 text-primary"/>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4 text-center">
|
||||||
|
{isDragging
|
||||||
|
? "Drop your audio files here"
|
||||||
|
: "Drag and drop audio files here, or click the button below to select"}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button onClick={handleSelectFiles} size="lg">
|
||||||
|
<Upload className="h-5 w-5"/>
|
||||||
|
Select Files
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSelectFolder} size="lg" variant="outline">
|
||||||
|
<Upload className="h-5 w-5"/>
|
||||||
|
Select Folder
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-4 text-center">
|
||||||
|
Supported format: FLAC
|
||||||
|
</p>
|
||||||
|
</>) : (<div className="w-full h-full p-6 space-y-4 flex flex-col">
|
||||||
|
<div className="space-y-2 pb-4 border-b shrink-0">
|
||||||
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label className="whitespace-nowrap">Bit Depth:</Label>
|
||||||
|
<ToggleGroup type="single" variant="outline" value={bitDepth} onValueChange={(value) => {
|
||||||
|
if (value)
|
||||||
|
setBitDepth(value);
|
||||||
|
}}>
|
||||||
|
{BIT_DEPTH_OPTIONS.map((option) => (<ToggleGroupItem key={option.value} value={option.value} aria-label={option.label}>
|
||||||
|
{option.label}
|
||||||
|
</ToggleGroupItem>))}
|
||||||
|
</ToggleGroup>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label className="whitespace-nowrap">Sample Rate:</Label>
|
||||||
|
<ToggleGroup type="single" variant="outline" value={sampleRate} onValueChange={(value) => {
|
||||||
|
if (value)
|
||||||
|
setSampleRate(value);
|
||||||
|
}}>
|
||||||
|
{SAMPLE_RATE_OPTIONS.map((option) => (<ToggleGroupItem key={option.value} value={option.value} aria-label={option.label}>
|
||||||
|
{option.label}
|
||||||
|
</ToggleGroupItem>))}
|
||||||
|
</ToggleGroup>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between shrink-0">
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{files.length} file(s) • {successCount} resampled
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 space-y-2 overflow-y-auto min-h-0">
|
||||||
|
{files.map((file) => {
|
||||||
|
const srcParts: string[] = [];
|
||||||
|
if (file.srcBitDepth)
|
||||||
|
srcParts.push(`${file.srcBitDepth}-bit`);
|
||||||
|
if (file.srcSampleRate)
|
||||||
|
srcParts.push(formatSampleRate(file.srcSampleRate));
|
||||||
|
const srcSpec = srcParts.join(" / ");
|
||||||
|
return (<div key={file.path} className="flex items-center gap-3 rounded-lg border p-3">
|
||||||
|
{getStatusIcon(file.status)}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="truncate text-sm font-medium">{file.name}</p>
|
||||||
|
{file.error && (<p className="truncate text-xs text-destructive">
|
||||||
|
{file.error}
|
||||||
|
</p>)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{srcSpec ? (<span className="text-xs font-medium text-primary bg-primary/10 rounded px-1.5 py-0.5 whitespace-nowrap shrink-0">
|
||||||
|
{srcSpec}
|
||||||
|
</span>) : file.status === "pending" ? (<span className="text-xs text-muted-foreground/50 whitespace-nowrap shrink-0">
|
||||||
|
reading...
|
||||||
|
</span>) : null}
|
||||||
|
|
||||||
|
<span className="text-xs text-muted-foreground shrink-0">
|
||||||
|
{formatFileSize(file.size)}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs uppercase text-muted-foreground shrink-0">
|
||||||
|
{file.format}
|
||||||
|
</span>
|
||||||
|
{file.status !== "resampling" && (<Button variant="ghost" size="icon" className="h-8 w-8 shrink-0" onClick={() => removeFile(file.path)} disabled={resampling}>
|
||||||
|
<X className="h-4 w-4"/>
|
||||||
|
</Button>)}
|
||||||
|
</div>);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-center pt-4 border-t shrink-0">
|
||||||
|
<Button onClick={handleResample} disabled={resampling || resampleableCount === 0} size="lg">
|
||||||
|
{resampling ? (<>
|
||||||
|
<Spinner className="h-4 w-4"/>
|
||||||
|
Resampling...
|
||||||
|
</>) : (<>
|
||||||
|
<AudioLinesIcon size={16} className="text-primary-foreground"/>
|
||||||
|
Resample{" "}
|
||||||
|
{resampleableCount > 0 ? `${resampleableCount} File(s)` : ""}
|
||||||
|
</>)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>)}
|
||||||
|
</div>
|
||||||
|
</div>);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from "react";
|
|||||||
import { Trash2, Copy, Check, FileDown } from "lucide-react";
|
import { Trash2, Copy, Check, FileDown } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { logger, type LogEntry } from "@/lib/logger";
|
import { logger, type LogEntry } from "@/lib/logger";
|
||||||
|
import { useDownloadQueueData } from "@/hooks/useDownloadQueueData";
|
||||||
import { ExportFailedDownloads } from "../../wailsjs/go/main/App";
|
import { ExportFailedDownloads } from "../../wailsjs/go/main/App";
|
||||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||||
const levelColors: Record<string, string> = {
|
const levelColors: Record<string, string> = {
|
||||||
@@ -23,6 +24,13 @@ export function DebugLoggerPage() {
|
|||||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const queueInfo = useDownloadQueueData();
|
||||||
|
const hasDownloadActivity = queueInfo.queue.length > 0 ||
|
||||||
|
queueInfo.queued_count > 0 ||
|
||||||
|
queueInfo.completed_count > 0 ||
|
||||||
|
queueInfo.failed_count > 0 ||
|
||||||
|
queueInfo.skipped_count > 0;
|
||||||
|
const canExportFailed = hasDownloadActivity && queueInfo.failed_count > 0;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsubscribe = logger.subscribe(() => {
|
const unsubscribe = logger.subscribe(() => {
|
||||||
setLogs(logger.getLogs());
|
setLogs(logger.getLogs());
|
||||||
@@ -54,6 +62,9 @@ export function DebugLoggerPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
const handleExportFailed = async () => {
|
const handleExportFailed = async () => {
|
||||||
|
if (!canExportFailed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const message = await ExportFailedDownloads();
|
const message = await ExportFailedDownloads();
|
||||||
if (message.startsWith("Successfully")) {
|
if (message.startsWith("Successfully")) {
|
||||||
@@ -72,7 +83,7 @@ export function DebugLoggerPage() {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-bold">Debug Logs</h1>
|
<h1 className="text-2xl font-bold">Debug Logs</h1>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button variant="outline" size="sm" className="gap-1.5" onClick={handleExportFailed}>
|
<Button variant="outline" size="sm" className="gap-1.5" onClick={handleExportFailed} disabled={!canExportFailed}>
|
||||||
<FileDown className="h-4 w-4"/>
|
<FileDown className="h-4 w-4"/>
|
||||||
Export Failed
|
Export Failed
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -13,18 +13,18 @@ export function DownloadProgressToast({ onClick }: DownloadProgressToastProps) {
|
|||||||
return null;
|
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">
|
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">
|
<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]">
|
<div className="flex flex-col min-w-[80px]">
|
||||||
<p className="text-sm font-medium font-mono tabular-nums">
|
<p className="text-sm font-medium font-mono tabular-nums">
|
||||||
{progress.mb_downloaded.toFixed(2)} MB
|
{progress.mb_downloaded.toFixed(2)} MB
|
||||||
</p>
|
</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
|
{progress.speed_mbps.toFixed(2)} MB/s
|
||||||
</p>)}
|
</p>)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</Button>
|
</Button>
|
||||||
</div>);
|
</div>);
|
||||||
|
|||||||
@@ -1,182 +0,0 @@
|
|||||||
import { useState, useEffect } from "react";
|
|
||||||
import type { DragEvent } from "react";
|
|
||||||
import { UploadImageBytes, UploadImage, SelectImageVideo } from "../../wailsjs/go/main/App";
|
|
||||||
import { Upload, Loader2, ImagePlus, X, Check, FileVideo, ImageIcon } from "lucide-react";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
interface UploadedFile {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
url: string;
|
|
||||||
type: 'image' | 'video' | 'unknown';
|
|
||||||
status: 'uploading' | 'done' | 'error';
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
interface DragDropMediaProps {
|
|
||||||
value: string;
|
|
||||||
onChange: (value: string) => void;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
export function DragDropMedia({ value, onChange, className }: DragDropMediaProps) {
|
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
|
||||||
const [files, setFiles] = useState<UploadedFile[]>(() => {
|
|
||||||
if (!value)
|
|
||||||
return [];
|
|
||||||
return value.split('\n').filter(line => line.trim()).map((line, i) => {
|
|
||||||
const match = line.match(/!\[(.*?)\]\((.*?)\)/);
|
|
||||||
if (match) {
|
|
||||||
return {
|
|
||||||
id: `init-${i}-${Date.now()}`,
|
|
||||||
name: match[1] === 'image' || match[1] === 'video' ? `file-${i}` : match[1],
|
|
||||||
url: match[2] || line,
|
|
||||||
type: (match[2] && match[2].match(/\.(mp4|mkv|webm|mov)$/i)) ? 'video' : 'image',
|
|
||||||
status: 'done'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
id: `init-${i}-${Date.now()}`,
|
|
||||||
name: 'unknown',
|
|
||||||
url: line,
|
|
||||||
type: 'image',
|
|
||||||
status: 'done'
|
|
||||||
};
|
|
||||||
});
|
|
||||||
});
|
|
||||||
useEffect(() => {
|
|
||||||
const newValue = files
|
|
||||||
.filter(f => f.status === 'done' && f.url)
|
|
||||||
.map(f => f.url)
|
|
||||||
.join('\n');
|
|
||||||
if (newValue !== value) {
|
|
||||||
onChange(newValue);
|
|
||||||
}
|
|
||||||
}, [files]);
|
|
||||||
const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
setIsDragging(true);
|
|
||||||
};
|
|
||||||
const handleDragLeave = (e: DragEvent<HTMLDivElement>) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
setIsDragging(false);
|
|
||||||
};
|
|
||||||
const handleDrop = async (e: DragEvent<HTMLDivElement>) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
setIsDragging(false);
|
|
||||||
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
|
||||||
await handleFiles(Array.from(e.dataTransfer.files));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const handleFiles = async (fileList: File[]) => {
|
|
||||||
const timestamp = Date.now();
|
|
||||||
const newFiles: UploadedFile[] = fileList.map((f, i) => ({
|
|
||||||
id: `drop-${timestamp}-${i}`,
|
|
||||||
name: f.name,
|
|
||||||
url: '',
|
|
||||||
type: f.type.startsWith('video') ? 'video' : 'image',
|
|
||||||
status: 'uploading'
|
|
||||||
}));
|
|
||||||
setFiles(prev => [...prev, ...newFiles]);
|
|
||||||
for (let i = 0; i < fileList.length; i++) {
|
|
||||||
const file = fileList[i];
|
|
||||||
const fileId = newFiles[i].id;
|
|
||||||
try {
|
|
||||||
const base64 = await fileToBase64(file);
|
|
||||||
const result = await UploadImageBytes(file.name, base64);
|
|
||||||
setFiles(prev => prev.map(f => f.id === fileId
|
|
||||||
? { ...f, status: 'done', url: result }
|
|
||||||
: f));
|
|
||||||
}
|
|
||||||
catch (err: any) {
|
|
||||||
console.error("Upload failed", err);
|
|
||||||
setFiles(prev => prev.map(f => f.id === fileId
|
|
||||||
? { ...f, status: 'error', error: err.message || "Upload failed" }
|
|
||||||
: f));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const handleSelectFile = async () => {
|
|
||||||
try {
|
|
||||||
const paths = await SelectImageVideo();
|
|
||||||
if (paths && paths.length > 0) {
|
|
||||||
const timestamp = Date.now();
|
|
||||||
const newFiles: UploadedFile[] = paths.map((p, i) => ({
|
|
||||||
id: `select-${timestamp}-${i}`,
|
|
||||||
name: p.split(/[\\/]/).pop() || 'unknown',
|
|
||||||
url: '',
|
|
||||||
type: p.match(/\.(mp4|mkv|webm|mov)$/i) ? 'video' : 'image',
|
|
||||||
status: 'uploading'
|
|
||||||
}));
|
|
||||||
setFiles(prev => [...prev, ...newFiles]);
|
|
||||||
for (let i = 0; i < paths.length; i++) {
|
|
||||||
const path = paths[i];
|
|
||||||
const fileId = newFiles[i].id;
|
|
||||||
try {
|
|
||||||
const result = await UploadImage(path);
|
|
||||||
setFiles(prev => prev.map(f => f.id === fileId
|
|
||||||
? { ...f, status: 'done', url: result }
|
|
||||||
: f));
|
|
||||||
}
|
|
||||||
catch (err: any) {
|
|
||||||
setFiles(prev => prev.map(f => f.id === fileId
|
|
||||||
? { ...f, status: 'error', error: err.message }
|
|
||||||
: f));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (err: any) {
|
|
||||||
console.error("Select file failed", err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const removeFile = (index: number) => {
|
|
||||||
setFiles(prev => prev.filter((_, i) => i !== index));
|
|
||||||
};
|
|
||||||
return (<div className={cn("relative group flex flex-col gap-2 p-4 border-2 border-dashed rounded-lg transition-colors border-muted-foreground/25 hover:border-primary/50 min-h-[14rem]", isDragging ? "border-primary bg-primary/10" : "bg-muted/5", className)} onDragOver={handleDragOver} onDragLeave={handleDragLeave} onDrop={handleDrop} onClick={(e) => {
|
|
||||||
if (e.target === e.currentTarget)
|
|
||||||
handleSelectFile();
|
|
||||||
}}>
|
|
||||||
{files.length === 0 && (<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none opacity-50">
|
|
||||||
<ImagePlus className="h-10 w-10 mb-2"/>
|
|
||||||
<span className="text-sm font-medium">Drop media here or click to browse</span>
|
|
||||||
<span className="text-xs text-muted-foreground mt-1">Supports PNG, JPG, MP4, MOV</span>
|
|
||||||
</div>)}
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 z-10 w-full">
|
|
||||||
{files.map((file, i) => (<div key={i} className="flex items-center gap-3 p-2 rounded-md bg-background/80 backdrop-blur-sm border shadow-sm animate-in fade-in slide-in-from-bottom-2">
|
|
||||||
{file.type === 'video' ? <FileVideo className="h-8 w-8 text-primary"/> : <ImageIcon className="h-8 w-8 text-primary"/>}
|
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="text-sm font-medium truncate">{file.name}</div>
|
|
||||||
<div className="text-xs text-muted-foreground flex items-center gap-1">
|
|
||||||
{file.status === 'uploading' && <span className="text-yellow-500 flex items-center"><Loader2 className="h-3 w-3 animate-spin mr-1"/> Uploading...</span>}
|
|
||||||
{file.status === 'done' && <span className="text-green-500 flex items-center"><Check className="h-3 w-3 mr-1"/> Ready</span>}
|
|
||||||
{file.status === 'error' && <span className="text-red-500">{file.error || 'Failed'}</span>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-destructive" onClick={(e) => { e.stopPropagation(); removeFile(i); }}>
|
|
||||||
<X className="h-4 w-4"/>
|
|
||||||
</Button>
|
|
||||||
</div>))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
{isDragging && (<div className="absolute inset-0 flex items-center justify-center bg-background/50 rounded-lg pointer-events-none z-20">
|
|
||||||
<div className="flex flex-col items-center text-primary font-medium">
|
|
||||||
<Upload className="h-10 w-10 mb-2 animate-bounce"/>
|
|
||||||
<span>Drop files to add</span>
|
|
||||||
</div>
|
|
||||||
</div>)}
|
|
||||||
</div>);
|
|
||||||
}
|
|
||||||
const fileToBase64 = (file: File): Promise<string> => {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
reader.onload = () => resolve(reader.result as string);
|
|
||||||
reader.onerror = (error) => reject(error);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -549,7 +549,7 @@ export function FileManagerPage() {
|
|||||||
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help"/>
|
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help"/>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="right">
|
<TooltipContent side="right">
|
||||||
<p className="text-xs whitespace-nowrap">Variables: {"{title}"}, {"{artist}"}, {"{album}"}, {"{album_artist}"}, {"{track}"}, {"{disc}"}, {"{year}"}</p>
|
<p className="text-xs whitespace-nowrap">Variables: {"{title}"}, {"{artist}"}, {"{album}"}, {"{album_artist}"}, {"{track}"}, {"{disc}"}, {"{year}"}, {"{date}"}</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
@@ -571,7 +571,7 @@ export function FileManagerPage() {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Preview: <span className="font-mono">{renameFormat.replace(/\{title\}/g, "All The Stars").replace(/\{artist\}/g, "Kendrick Lamar, SZA").replace(/\{album\}/g, "Black Panther").replace(/\{album_artist\}/g, "Kendrick Lamar").replace(/\{track\}/g, "01").replace(/\{disc\}/g, "1").replace(/\{year\}/g, "2018")}.flac</span>
|
Preview: <span className="font-mono">{renameFormat.replace(/\{title\}/g, "All The Stars").replace(/\{artist\}/g, "Kendrick Lamar, SZA").replace(/\{album\}/g, "Black Panther").replace(/\{album_artist\}/g, "Kendrick Lamar").replace(/\{track\}/g, "01").replace(/\{disc\}/g, "1").replace(/\{year\}/g, "2018").replace(/\{date\}/g, "2018-02-09")}.flac</span>
|
||||||
</p>
|
</p>
|
||||||
</div>)}
|
</div>)}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, Pagi
|
|||||||
import { GetDownloadHistory, ClearDownloadHistory, GetPreviewURL, GetFetchHistory, DeleteDownloadHistoryItem, DeleteFetchHistoryItem, ClearFetchHistoryByType } from "../../wailsjs/go/main/App";
|
import { GetDownloadHistory, ClearDownloadHistory, GetPreviewURL, GetFetchHistory, DeleteDownloadHistoryItem, DeleteFetchHistoryItem, ClearFetchHistoryByType } from "../../wailsjs/go/main/App";
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { openExternal } from "@/lib/utils";
|
import { openExternal } from "@/lib/utils";
|
||||||
|
import { SPOTIFY_PREVIEW_VOLUME } from "@/lib/preview";
|
||||||
|
import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons";
|
||||||
const formatDate = (timestamp: number) => {
|
const formatDate = (timestamp: number) => {
|
||||||
const date = new Date(timestamp * 1000);
|
const date = new Date(timestamp * 1000);
|
||||||
const year = date.getFullYear();
|
const year = date.getFullYear();
|
||||||
@@ -30,6 +32,7 @@ interface DownloadHistoryItem {
|
|||||||
quality: string;
|
quality: string;
|
||||||
format: string;
|
format: string;
|
||||||
path: string;
|
path: string;
|
||||||
|
source: string;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
}
|
}
|
||||||
interface FetchHistoryItem {
|
interface FetchHistoryItem {
|
||||||
@@ -62,10 +65,35 @@ export function HistoryPage({ onHistorySelect }: HistoryPageProps) {
|
|||||||
const [fetchSearchQuery, setFetchSearchQuery] = useState("");
|
const [fetchSearchQuery, setFetchSearchQuery] = useState("");
|
||||||
const [fetchCurrentPage, setFetchCurrentPage] = useState(1);
|
const [fetchCurrentPage, setFetchCurrentPage] = useState(1);
|
||||||
const ITEMS_PER_PAGE = 50;
|
const ITEMS_PER_PAGE = 50;
|
||||||
|
const getTrackLink = (spotifyId: string) => {
|
||||||
|
if (spotifyId?.startsWith("tidal_"))
|
||||||
|
return { url: `https://listen.tidal.com/track/${spotifyId.replace("tidal_", "")}`, label: "Open in Tidal" };
|
||||||
|
if (spotifyId?.startsWith("qobuz_"))
|
||||||
|
return { url: `https://www.qobuz.com/track/${spotifyId.replace("qobuz_", "")}`, label: "Open in Qobuz" };
|
||||||
|
if (spotifyId?.startsWith("amazon_"))
|
||||||
|
return { url: `https://music.amazon.com/tracks/${spotifyId.replace("amazon_", "")}`, label: "Open in Amazon Music" };
|
||||||
|
if (spotifyId?.startsWith("deezer_"))
|
||||||
|
return { url: `https://www.deezer.com/track/${spotifyId.replace("deezer_", "")}`, label: "Open in Deezer" };
|
||||||
|
return { url: `https://open.spotify.com/track/${spotifyId}`, label: "Open in Spotify" };
|
||||||
|
};
|
||||||
|
const getSourceIcon = (source: string) => {
|
||||||
|
const s = source?.toLowerCase() || "";
|
||||||
|
if (s.includes("tidal"))
|
||||||
|
return <TidalIcon className="h-4 w-4 object-contain rounded"/>;
|
||||||
|
if (s.includes("qobuz"))
|
||||||
|
return <QobuzIcon className="h-4 w-4 object-contain"/>;
|
||||||
|
if (s.includes("amazon"))
|
||||||
|
return <AmazonIcon className="h-4 w-4 object-contain rounded"/>;
|
||||||
|
if (s.includes("deezer"))
|
||||||
|
return <Music2 className="h-4 w-4"/>;
|
||||||
|
if (s.includes("spotify"))
|
||||||
|
return <Music2 className="h-4 w-4"/>;
|
||||||
|
return <Music2 className="h-4 w-4 opacity-50"/>;
|
||||||
|
};
|
||||||
const fetchDownloadHistory = async () => {
|
const fetchDownloadHistory = async () => {
|
||||||
try {
|
try {
|
||||||
const items = await GetDownloadHistory();
|
const items = await GetDownloadHistory();
|
||||||
setDownloadHistory(items || []);
|
setDownloadHistory((items || []) as unknown as DownloadHistoryItem[]);
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
console.error("Failed to fetch download history:", err);
|
console.error("Failed to fetch download history:", err);
|
||||||
@@ -164,7 +192,7 @@ export function HistoryPage({ onHistorySelect }: HistoryPageProps) {
|
|||||||
if (url) {
|
if (url) {
|
||||||
const audio = new Audio(url);
|
const audio = new Audio(url);
|
||||||
audioRef.current = audio;
|
audioRef.current = audio;
|
||||||
audio.volume = 0.5;
|
audio.volume = SPOTIFY_PREVIEW_VOLUME;
|
||||||
audio.onended = () => setPlayingPreviewId(null);
|
audio.onended = () => setPlayingPreviewId(null);
|
||||||
audio.play();
|
audio.play();
|
||||||
setPlayingPreviewId(id);
|
setPlayingPreviewId(id);
|
||||||
@@ -228,8 +256,8 @@ export function HistoryPage({ onHistorySelect }: HistoryPageProps) {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<h2 className="text-xl font-bold tracking-tight">Downloads</h2>
|
<h2 className="text-xl font-bold tracking-tight">Downloads</h2>
|
||||||
{downloadHistory.length > 0 && (<Badge variant="secondary" className="font-mono">
|
{filteredDownloadHistory.length > 0 && (<Badge variant="secondary" className="font-mono">
|
||||||
{downloadHistory.length.toLocaleString('en-US')}
|
{filteredDownloadHistory.length.toLocaleString('en-US')}
|
||||||
</Badge>)}
|
</Badge>)}
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="sm" onClick={() => setShowClearDownloadConfirm(true)} disabled={downloadHistory.length === 0} className="cursor-pointer gap-2">
|
<Button variant="outline" size="sm" onClick={() => setShowClearDownloadConfirm(true)} disabled={downloadHistory.length === 0} className="cursor-pointer gap-2">
|
||||||
@@ -275,11 +303,12 @@ export function HistoryPage({ onHistorySelect }: HistoryPageProps) {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b bg-muted/50">
|
<tr className="border-b bg-muted/50">
|
||||||
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground w-12 text-xs uppercase">#</th>
|
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground w-12 text-xs uppercase">#</th>
|
||||||
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground text-xs uppercase">Title</th>
|
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground text-xs uppercase w-[35%]">Title</th>
|
||||||
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground hidden md:table-cell text-xs uppercase w-1/4">Album</th>
|
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground hidden md:table-cell text-xs uppercase w-48 lg:w-48 xl:w-56">Album</th>
|
||||||
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground hidden lg:table-cell w-32 text-xs uppercase">Format</th>
|
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground hidden lg:table-cell w-32 text-xs uppercase">Format</th>
|
||||||
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground hidden xl:table-cell w-16 text-xs uppercase text-nowrap">Dur</th>
|
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground hidden xl:table-cell w-16 text-xs uppercase text-nowrap">Dur</th>
|
||||||
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground hidden md:table-cell w-36 text-xs uppercase text-nowrap">Downloaded At</th>
|
<th className="h-10 px-4 text-left align-middle font-medium text-muted-foreground hidden md:table-cell w-36 text-xs uppercase text-nowrap">Downloaded At</th>
|
||||||
|
<th className="h-10 px-4 text-center align-middle font-medium text-muted-foreground w-16 text-xs uppercase text-nowrap">Source</th>
|
||||||
<th className="h-10 px-4 text-center align-middle font-medium text-muted-foreground w-32 text-xs uppercase text-nowrap">Actions</th>
|
<th className="h-10 px-4 text-center align-middle font-medium text-muted-foreground w-32 text-xs uppercase text-nowrap">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -303,7 +332,7 @@ export function HistoryPage({ onHistorySelect }: HistoryPageProps) {
|
|||||||
<td className="p-3 align-middle text-left hidden lg:table-cell">
|
<td className="p-3 align-middle text-left hidden lg:table-cell">
|
||||||
<div className="flex flex-col items-start gap-1">
|
<div className="flex flex-col items-start gap-1">
|
||||||
<span className="text-xs font-bold text-foreground">
|
<span className="text-xs font-bold text-foreground">
|
||||||
{['HI_RES_LOSSLESS', 'LOSSLESS'].includes(item.format) ? 'FLAC' : item.format}
|
{['HI_RES_LOSSLESS', 'LOSSLESS', 'flac', '6', '7', '27'].includes(item.format?.toLowerCase() || '') ? 'FLAC' : item.format?.toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
{item.quality && <span className="text-[11px] text-muted-foreground leading-none whitespace-nowrap">{item.quality}</span>}
|
{item.quality && <span className="text-[11px] text-muted-foreground leading-none whitespace-nowrap">{item.quality}</span>}
|
||||||
</div>
|
</div>
|
||||||
@@ -311,36 +340,52 @@ export function HistoryPage({ onHistorySelect }: HistoryPageProps) {
|
|||||||
<td className="p-3 align-middle text-sm text-muted-foreground text-left hidden xl:table-cell font-mono">
|
<td className="p-3 align-middle text-sm text-muted-foreground text-left hidden xl:table-cell font-mono">
|
||||||
{item.duration_str}
|
{item.duration_str}
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3 align-middle text-xs text-muted-foreground hidden md:table-cell whitespace-nowrap">
|
<td className="p-3 align-middle text-xs text-muted-foreground hidden md:table-cell whitespace-nowrap text-left">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span>{formatDate(item.timestamp).split(' ')[0]}</span>
|
<span>{formatDate(item.timestamp).split(' ')[0]}</span>
|
||||||
<span className="text-[10px] text-muted-foreground">{formatDate(item.timestamp).split(' ')[1]}</span>
|
<span className="text-[10px] text-muted-foreground">{formatDate(item.timestamp).split(' ')[1]}</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3 align-middle text-center">
|
<td className="p-3 align-middle text-center">
|
||||||
<div className="flex items-center justify-center gap-1">
|
<div className="flex items-center justify-center">
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip delayDuration={0}>
|
<Tooltip delayDuration={0}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8 cursor-pointer" onClick={() => handlePreview(item.id, item.spotify_id)} disabled={!item.spotify_id}>
|
<div className="flex items-center justify-center">
|
||||||
{playingPreviewId === item.id ? <Pause className="h-4 w-4"/> : <Play className="h-4 w-4"/>}
|
{getSourceIcon(item.source)}
|
||||||
</Button>
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>{playingPreviewId === item.id ? "Pause Preview" : "Play Preview"}</p>
|
<p className="capitalize">{item.source || "Unknown"}</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="p-3 align-middle text-center">
|
||||||
|
<div className="flex items-center justify-center gap-1">
|
||||||
|
{!(item.spotify_id?.startsWith('tidal_') || item.spotify_id?.startsWith('qobuz_') || item.spotify_id?.startsWith('amazon_') || item.spotify_id?.startsWith('deezer_')) && (<TooltipProvider>
|
||||||
|
<Tooltip delayDuration={0}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8 cursor-pointer" onClick={() => handlePreview(item.id, item.spotify_id)} disabled={!item.spotify_id}>
|
||||||
|
{playingPreviewId === item.id ? <Pause className="h-4 w-4"/> : <Play className="h-4 w-4"/>}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{playingPreviewId === item.id ? "Pause Preview" : "Play Preview"}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>)}
|
||||||
|
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip delayDuration={0}>
|
<Tooltip delayDuration={0}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8 cursor-pointer" onClick={() => openExternal(`https://open.spotify.com/track/${item.spotify_id}`)}>
|
<Button variant="ghost" size="icon" className="h-8 w-8 cursor-pointer" onClick={() => openExternal(getTrackLink(item.spotify_id).url)}>
|
||||||
<ExternalLink className="h-4 w-4"/>
|
<ExternalLink className="h-4 w-4"/>
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>Open in Spotify</p>
|
<p>{getTrackLink(item.spotify_id).label}</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|||||||
@@ -6,6 +6,12 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
|
|||||||
import { SearchAndSort } from "./SearchAndSort";
|
import { SearchAndSort } from "./SearchAndSort";
|
||||||
import { TrackList } from "./TrackList";
|
import { TrackList } from "./TrackList";
|
||||||
import { DownloadProgress } from "./DownloadProgress";
|
import { DownloadProgress } from "./DownloadProgress";
|
||||||
|
import { getSettings } from "@/lib/settings";
|
||||||
|
import { downloadCover } from "@/lib/api";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||||
|
import { joinPath, sanitizePath } from "@/lib/utils";
|
||||||
|
import { parseTemplate, type TemplateData } from "@/lib/settings";
|
||||||
import type { TrackMetadata, TrackAvailability } from "@/types/api";
|
import type { TrackMetadata, TrackAvailability } from "@/types/api";
|
||||||
interface PlaylistInfoProps {
|
interface PlaylistInfoProps {
|
||||||
playlistInfo: {
|
playlistInfo: {
|
||||||
@@ -54,9 +60,9 @@ interface PlaylistInfoProps {
|
|||||||
isBulkDownloadingLyrics?: boolean;
|
isBulkDownloadingLyrics?: boolean;
|
||||||
onSearchChange: (value: string) => void;
|
onSearchChange: (value: string) => void;
|
||||||
onSortChange: (value: string) => void;
|
onSortChange: (value: string) => void;
|
||||||
onToggleTrack: (isrc: string) => void;
|
onToggleTrack: (id: string) => void;
|
||||||
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
|
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
|
||||||
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => void;
|
onDownloadTrack: (id: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => void;
|
||||||
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
|
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
|
||||||
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
|
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
|
||||||
onCheckAvailability?: (spotifyId: string) => void;
|
onCheckAvailability?: (spotifyId: string) => void;
|
||||||
@@ -81,6 +87,66 @@ interface PlaylistInfoProps {
|
|||||||
onBack?: () => void;
|
onBack?: () => void;
|
||||||
}
|
}
|
||||||
export function PlaylistInfo({ playlistInfo, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onPageChange, onAlbumClick, onArtistClick, onTrackClick, onBack, }: PlaylistInfoProps) {
|
export function PlaylistInfo({ playlistInfo, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onPageChange, onAlbumClick, onArtistClick, onTrackClick, onBack, }: PlaylistInfoProps) {
|
||||||
|
const settings = getSettings();
|
||||||
|
const [downloadingPlaylistCover, setDownloadingPlaylistCover] = useState(false);
|
||||||
|
const handleDownloadPlaylistCover = async () => {
|
||||||
|
if (!playlistInfo.cover)
|
||||||
|
return;
|
||||||
|
setDownloadingPlaylistCover(true);
|
||||||
|
try {
|
||||||
|
const os = settings.operatingSystem;
|
||||||
|
let outputDir = settings.downloadPath;
|
||||||
|
const playlistName = playlistInfo.owner.name;
|
||||||
|
const placeholder = "__SLASH_PLACEHOLDER__";
|
||||||
|
const templateData: TemplateData = {
|
||||||
|
artist: "",
|
||||||
|
album: "",
|
||||||
|
album_artist: "",
|
||||||
|
title: playlistName.replace(/\//g, placeholder),
|
||||||
|
playlist: playlistName.replace(/\//g, placeholder),
|
||||||
|
};
|
||||||
|
if (settings.createPlaylistFolder && playlistName) {
|
||||||
|
outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os));
|
||||||
|
}
|
||||||
|
if (settings.folderTemplate) {
|
||||||
|
const folderPath = parseTemplate(settings.folderTemplate, templateData);
|
||||||
|
if (folderPath) {
|
||||||
|
const parts = folderPath.split("/").filter((p: string) => p.trim());
|
||||||
|
for (const part of parts) {
|
||||||
|
outputDir = joinPath(os, outputDir, sanitizePath(part.replace(new RegExp(placeholder, "g"), " "), os));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const response = await downloadCover({
|
||||||
|
cover_url: playlistInfo.cover,
|
||||||
|
track_name: playlistName,
|
||||||
|
artist_name: "",
|
||||||
|
album_name: "",
|
||||||
|
album_artist: "",
|
||||||
|
release_date: "",
|
||||||
|
output_dir: outputDir,
|
||||||
|
filename_format: "title",
|
||||||
|
track_number: false,
|
||||||
|
position: 0,
|
||||||
|
disc_number: 0,
|
||||||
|
});
|
||||||
|
if (response.success) {
|
||||||
|
if (response.already_exists)
|
||||||
|
toast.info("Cover already exists");
|
||||||
|
else
|
||||||
|
toast.success("Separate playlist cover downloaded");
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
toast.error(response.error || "Failed to download cover");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "Failed to download cover");
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
setDownloadingPlaylistCover(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
return (<div className="space-y-6">
|
return (<div className="space-y-6">
|
||||||
<Card className="relative">
|
<Card className="relative">
|
||||||
{onBack && (<div className="absolute top-4 right-4 z-10">
|
{onBack && (<div className="absolute top-4 right-4 z-10">
|
||||||
@@ -90,7 +156,19 @@ export function PlaylistInfo({ playlistInfo, trackList, searchQuery, sortBy, sel
|
|||||||
</div>)}
|
</div>)}
|
||||||
<CardContent className="px-6">
|
<CardContent className="px-6">
|
||||||
<div className="flex gap-6 items-start">
|
<div className="flex gap-6 items-start">
|
||||||
{playlistInfo.cover && (<img src={playlistInfo.cover} alt={playlistInfo.owner.name} className="w-48 h-48 rounded-md shadow-lg object-cover"/>)}
|
{playlistInfo.cover && (<div className="relative group shrink-0 w-48 h-48">
|
||||||
|
<img src={playlistInfo.cover} alt={playlistInfo.owner.name} className="w-48 h-48 rounded-md shadow-lg object-cover"/>
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity rounded-md">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button variant="secondary" size="icon" className="h-9 w-9 shadow-lg" onClick={handleDownloadPlaylistCover} disabled={downloadingPlaylistCover}>
|
||||||
|
{downloadingPlaylistCover ? <Spinner /> : <ImageDown className="h-4 w-4"/>}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent><p>Download Separate Playlist Cover</p></TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>)}
|
||||||
<div className="flex-1 space-y-4">
|
<div className="flex-1 space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-sm font-medium">Playlist</p>
|
<p className="text-sm font-medium">Playlist</p>
|
||||||
@@ -135,7 +213,7 @@ export function PlaylistInfo({ playlistInfo, trackList, searchQuery, sortBy, sel
|
|||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>Download All Covers</p>
|
<p>Download All Separate Covers</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>)}
|
</Tooltip>)}
|
||||||
{downloadedTracks.size > 0 && (<Button onClick={onOpenFolder} variant="outline">
|
{downloadedTracks.size > 0 && (<Button onClick={onOpenFolder} variant="outline">
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export function SearchAndSort({ searchQuery, sortBy, onSearchChange, onSortChang
|
|||||||
<SelectItem value="plays-desc">Plays (High)</SelectItem>
|
<SelectItem value="plays-desc">Plays (High)</SelectItem>
|
||||||
<SelectItem value="downloaded">Downloaded</SelectItem>
|
<SelectItem value="downloaded">Downloaded</SelectItem>
|
||||||
<SelectItem value="not-downloaded">Not Downloaded</SelectItem>
|
<SelectItem value="not-downloaded">Not Downloaded</SelectItem>
|
||||||
|
<SelectItem value="failed">Failed Downloads</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>);
|
</div>);
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef, useMemo } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
import { InputWithContext } from "@/components/ui/input-with-context";
|
import { InputWithContext } from "@/components/ui/input-with-context";
|
||||||
import { CloudDownload, XCircle, Link, Search, X, ChevronDown } from "lucide-react";
|
import { CloudDownload, XCircle, Link, Search, X, ChevronDown, ArrowUpDown, } from "lucide-react";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
|
||||||
import { FetchHistory } from "@/components/FetchHistory";
|
import { FetchHistory } from "@/components/FetchHistory";
|
||||||
@@ -10,12 +11,13 @@ import { SearchSpotify, SearchSpotifyByType } from "../../wailsjs/go/main/App";
|
|||||||
import { backend } from "../../wailsjs/go/models";
|
import { backend } from "../../wailsjs/go/models";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useTypingEffect } from "@/hooks/useTypingEffect";
|
import { useTypingEffect } from "@/hooks/useTypingEffect";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select";
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog";
|
||||||
const FETCH_PLACEHOLDERS = [
|
const FETCH_PLACEHOLDERS = [
|
||||||
"https://open.spotify.com/track/...",
|
"https://open.spotify.com/track/...",
|
||||||
"https://open.spotify.com/album/...",
|
"https://open.spotify.com/album/...",
|
||||||
"https://open.spotify.com/playlist/...",
|
"https://open.spotify.com/playlist/...",
|
||||||
"https://open.spotify.com/artist/..."
|
"https://open.spotify.com/artist/...",
|
||||||
];
|
];
|
||||||
const SEARCH_PLACEHOLDERS = [
|
const SEARCH_PLACEHOLDERS = [
|
||||||
"Golden",
|
"Golden",
|
||||||
@@ -23,10 +25,194 @@ const SEARCH_PLACEHOLDERS = [
|
|||||||
"The Weeknd",
|
"The Weeknd",
|
||||||
"Starboy",
|
"Starboy",
|
||||||
"Joji",
|
"Joji",
|
||||||
"Die For You"
|
"Die For You",
|
||||||
];
|
];
|
||||||
const REGIONS = ["AD", "AE", "AG", "AL", "AM", "AO", "AR", "AT", "AU", "AZ", "BA", "BB", "BD", "BE", "BF", "BG", "BH", "BI", "BJ", "BN", "BO", "BR", "BS", "BT", "BW", "BZ", "CA", "CD", "CG", "CH", "CI", "CL", "CM", "CO", "CR", "CV", "CW", "CY", "CZ", "DE", "DJ", "DK", "DM", "DO", "DZ", "EC", "EE", "EG", "ES", "ET", "FI", "FJ", "FM", "FR", "GA", "GB", "GD", "GE", "GH", "GM", "GN", "GQ", "GR", "GT", "GW", "GY", "HK", "HN", "HR", "HT", "HU", "ID", "IE", "IL", "IN", "IQ", "IS", "IT", "JM", "JO", "JP", "KE", "KG", "KH", "KI", "KM", "KN", "KR", "KW", "KZ", "LA", "LB", "LC", "LI", "LK", "LR", "LS", "LT", "LU", "LV", "LY", "MA", "MC", "MD", "ME", "MG", "MH", "MK", "ML", "MN", "MO", "MR", "MT", "MU", "MV", "MW", "MX", "MY", "MZ", "NA", "NE", "NG", "NI", "NL", "NO", "NP", "NR", "NZ", "OM", "PA", "PE", "PG", "PH", "PK", "PL", "PS", "PT", "PW", "PY", "QA", "RO", "RS", "RW", "SA", "SB", "SC", "SE", "SG", "SI", "SK", "SL", "SM", "SN", "SR", "ST", "SV", "SZ", "TD", "TG", "TH", "TJ", "TL", "TN", "TO", "TR", "TT", "TV", "TW", "TZ", "UA", "UG", "US", "UY", "UZ", "VC", "VE", "VN", "VU", "WS", "XK", "ZA", "ZM", "ZW"];
|
const REGIONS = [
|
||||||
const regionNames = new Intl.DisplayNames(['en'], { type: 'region' });
|
"AD",
|
||||||
|
"AE",
|
||||||
|
"AG",
|
||||||
|
"AL",
|
||||||
|
"AM",
|
||||||
|
"AO",
|
||||||
|
"AR",
|
||||||
|
"AT",
|
||||||
|
"AU",
|
||||||
|
"AZ",
|
||||||
|
"BA",
|
||||||
|
"BB",
|
||||||
|
"BD",
|
||||||
|
"BE",
|
||||||
|
"BF",
|
||||||
|
"BG",
|
||||||
|
"BH",
|
||||||
|
"BI",
|
||||||
|
"BJ",
|
||||||
|
"BN",
|
||||||
|
"BO",
|
||||||
|
"BR",
|
||||||
|
"BS",
|
||||||
|
"BT",
|
||||||
|
"BW",
|
||||||
|
"BZ",
|
||||||
|
"CA",
|
||||||
|
"CD",
|
||||||
|
"CG",
|
||||||
|
"CH",
|
||||||
|
"CI",
|
||||||
|
"CL",
|
||||||
|
"CM",
|
||||||
|
"CO",
|
||||||
|
"CR",
|
||||||
|
"CV",
|
||||||
|
"CW",
|
||||||
|
"CY",
|
||||||
|
"CZ",
|
||||||
|
"DE",
|
||||||
|
"DJ",
|
||||||
|
"DK",
|
||||||
|
"DM",
|
||||||
|
"DO",
|
||||||
|
"DZ",
|
||||||
|
"EC",
|
||||||
|
"EE",
|
||||||
|
"EG",
|
||||||
|
"ES",
|
||||||
|
"ET",
|
||||||
|
"FI",
|
||||||
|
"FJ",
|
||||||
|
"FM",
|
||||||
|
"FR",
|
||||||
|
"GA",
|
||||||
|
"GB",
|
||||||
|
"GD",
|
||||||
|
"GE",
|
||||||
|
"GH",
|
||||||
|
"GM",
|
||||||
|
"GN",
|
||||||
|
"GQ",
|
||||||
|
"GR",
|
||||||
|
"GT",
|
||||||
|
"GW",
|
||||||
|
"GY",
|
||||||
|
"HK",
|
||||||
|
"HN",
|
||||||
|
"HR",
|
||||||
|
"HT",
|
||||||
|
"HU",
|
||||||
|
"ID",
|
||||||
|
"IE",
|
||||||
|
"IL",
|
||||||
|
"IN",
|
||||||
|
"IQ",
|
||||||
|
"IS",
|
||||||
|
"IT",
|
||||||
|
"JM",
|
||||||
|
"JO",
|
||||||
|
"JP",
|
||||||
|
"KE",
|
||||||
|
"KG",
|
||||||
|
"KH",
|
||||||
|
"KI",
|
||||||
|
"KM",
|
||||||
|
"KN",
|
||||||
|
"KR",
|
||||||
|
"KW",
|
||||||
|
"KZ",
|
||||||
|
"LA",
|
||||||
|
"LB",
|
||||||
|
"LC",
|
||||||
|
"LI",
|
||||||
|
"LK",
|
||||||
|
"LR",
|
||||||
|
"LS",
|
||||||
|
"LT",
|
||||||
|
"LU",
|
||||||
|
"LV",
|
||||||
|
"LY",
|
||||||
|
"MA",
|
||||||
|
"MC",
|
||||||
|
"MD",
|
||||||
|
"ME",
|
||||||
|
"MG",
|
||||||
|
"MH",
|
||||||
|
"MK",
|
||||||
|
"ML",
|
||||||
|
"MN",
|
||||||
|
"MO",
|
||||||
|
"MR",
|
||||||
|
"MT",
|
||||||
|
"MU",
|
||||||
|
"MV",
|
||||||
|
"MW",
|
||||||
|
"MX",
|
||||||
|
"MY",
|
||||||
|
"MZ",
|
||||||
|
"NA",
|
||||||
|
"NE",
|
||||||
|
"NG",
|
||||||
|
"NI",
|
||||||
|
"NL",
|
||||||
|
"NO",
|
||||||
|
"NP",
|
||||||
|
"NR",
|
||||||
|
"NZ",
|
||||||
|
"OM",
|
||||||
|
"PA",
|
||||||
|
"PE",
|
||||||
|
"PG",
|
||||||
|
"PH",
|
||||||
|
"PK",
|
||||||
|
"PL",
|
||||||
|
"PS",
|
||||||
|
"PT",
|
||||||
|
"PW",
|
||||||
|
"PY",
|
||||||
|
"QA",
|
||||||
|
"RO",
|
||||||
|
"RS",
|
||||||
|
"RW",
|
||||||
|
"SA",
|
||||||
|
"SB",
|
||||||
|
"SC",
|
||||||
|
"SE",
|
||||||
|
"SG",
|
||||||
|
"SI",
|
||||||
|
"SK",
|
||||||
|
"SL",
|
||||||
|
"SM",
|
||||||
|
"SN",
|
||||||
|
"SR",
|
||||||
|
"ST",
|
||||||
|
"SV",
|
||||||
|
"SZ",
|
||||||
|
"TD",
|
||||||
|
"TG",
|
||||||
|
"TH",
|
||||||
|
"TJ",
|
||||||
|
"TL",
|
||||||
|
"TN",
|
||||||
|
"TO",
|
||||||
|
"TR",
|
||||||
|
"TT",
|
||||||
|
"TV",
|
||||||
|
"TW",
|
||||||
|
"TZ",
|
||||||
|
"UA",
|
||||||
|
"UG",
|
||||||
|
"US",
|
||||||
|
"UY",
|
||||||
|
"UZ",
|
||||||
|
"VC",
|
||||||
|
"VE",
|
||||||
|
"VN",
|
||||||
|
"VU",
|
||||||
|
"WS",
|
||||||
|
"XK",
|
||||||
|
"ZA",
|
||||||
|
"ZM",
|
||||||
|
"ZW",
|
||||||
|
];
|
||||||
|
const regionNames = new Intl.DisplayNames(["en"], { type: "region" });
|
||||||
const getRegionName = (code: string) => {
|
const getRegionName = (code: string) => {
|
||||||
try {
|
try {
|
||||||
if (code === "XK")
|
if (code === "XK")
|
||||||
@@ -56,9 +242,16 @@ interface SearchBarProps {
|
|||||||
region: string;
|
region: string;
|
||||||
onRegionChange: (region: string) => void;
|
onRegionChange: (region: string) => void;
|
||||||
}
|
}
|
||||||
export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, history, onHistorySelect, onHistoryRemove, hasResult, searchMode, onSearchModeChange, region, onRegionChange }: SearchBarProps) {
|
export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, history, onHistorySelect, onHistoryRemove, hasResult, searchMode, onSearchModeChange, region, onRegionChange, }: SearchBarProps) {
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [searchResults, setSearchResults] = useState<backend.SearchResponse | null>(null);
|
const [searchResults, setSearchResults] = useState<backend.SearchResponse | null>(null);
|
||||||
|
const [resultFilter, setResultFilter] = useState("");
|
||||||
|
const [sortOrders, setSortOrders] = useState<Record<ResultTab, string>>({
|
||||||
|
tracks: "default",
|
||||||
|
albums: "default",
|
||||||
|
artists: "default",
|
||||||
|
playlists: "default",
|
||||||
|
});
|
||||||
const [isSearching, setIsSearching] = useState(false);
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||||
const [lastSearchedQuery, setLastSearchedQuery] = useState("");
|
const [lastSearchedQuery, setLastSearchedQuery] = useState("");
|
||||||
@@ -70,6 +263,8 @@ export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, hist
|
|||||||
artists: false,
|
artists: false,
|
||||||
playlists: false,
|
playlists: false,
|
||||||
});
|
});
|
||||||
|
const [showInvalidUrlDialog, setShowInvalidUrlDialog] = useState(false);
|
||||||
|
const [invalidUrl, setInvalidUrl] = useState("");
|
||||||
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const placeholders = searchMode ? SEARCH_PLACEHOLDERS : FETCH_PLACEHOLDERS;
|
const placeholders = searchMode ? SEARCH_PLACEHOLDERS : FETCH_PLACEHOLDERS;
|
||||||
const placeholderText = useTypingEffect(placeholders);
|
const placeholderText = useTypingEffect(placeholders);
|
||||||
@@ -125,8 +320,12 @@ export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, hist
|
|||||||
searchTimeoutRef.current = setTimeout(async () => {
|
searchTimeoutRef.current = setTimeout(async () => {
|
||||||
setIsSearching(true);
|
setIsSearching(true);
|
||||||
try {
|
try {
|
||||||
const results = await SearchSpotify({ query: searchQuery, limit: SEARCH_LIMIT });
|
const results = await SearchSpotify({
|
||||||
|
query: searchQuery,
|
||||||
|
limit: SEARCH_LIMIT,
|
||||||
|
});
|
||||||
setSearchResults(results);
|
setSearchResults(results);
|
||||||
|
setResultFilter("");
|
||||||
setLastSearchedQuery(searchQuery.trim());
|
setLastSearchedQuery(searchQuery.trim());
|
||||||
saveRecentSearch(searchQuery.trim());
|
saveRecentSearch(searchQuery.trim());
|
||||||
setHasMore({
|
setHasMore({
|
||||||
@@ -181,10 +380,18 @@ export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, hist
|
|||||||
if (!prev)
|
if (!prev)
|
||||||
return prev;
|
return prev;
|
||||||
const updated = new backend.SearchResponse({
|
const updated = new backend.SearchResponse({
|
||||||
tracks: activeTab === "tracks" ? [...prev.tracks, ...moreResults] : prev.tracks,
|
tracks: activeTab === "tracks"
|
||||||
albums: activeTab === "albums" ? [...prev.albums, ...moreResults] : prev.albums,
|
? [...prev.tracks, ...moreResults]
|
||||||
artists: activeTab === "artists" ? [...prev.artists, ...moreResults] : prev.artists,
|
: prev.tracks,
|
||||||
playlists: activeTab === "playlists" ? [...prev.playlists, ...moreResults] : prev.playlists,
|
albums: activeTab === "albums"
|
||||||
|
? [...prev.albums, ...moreResults]
|
||||||
|
: prev.albums,
|
||||||
|
artists: activeTab === "artists"
|
||||||
|
? [...prev.artists, ...moreResults]
|
||||||
|
: prev.artists,
|
||||||
|
playlists: activeTab === "playlists"
|
||||||
|
? [...prev.playlists, ...moreResults]
|
||||||
|
: prev.playlists,
|
||||||
});
|
});
|
||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
@@ -201,6 +408,35 @@ export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, hist
|
|||||||
setIsLoadingMore(false);
|
setIsLoadingMore(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const isSpotifyUrl = (text: string) => {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (!trimmed)
|
||||||
|
return true;
|
||||||
|
const isUrl = /^(https?:\/\/|www\.)/i.test(trimmed) || /^spotify:/i.test(trimmed);
|
||||||
|
if (!isUrl)
|
||||||
|
return true;
|
||||||
|
return (trimmed.includes("spotify.com") ||
|
||||||
|
trimmed.includes("spotify.link") ||
|
||||||
|
trimmed.startsWith("spotify:"));
|
||||||
|
};
|
||||||
|
const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
|
||||||
|
if (searchMode)
|
||||||
|
return;
|
||||||
|
const pastedText = e.clipboardData.getData("text");
|
||||||
|
if (pastedText && !isSpotifyUrl(pastedText)) {
|
||||||
|
e.preventDefault();
|
||||||
|
setInvalidUrl(pastedText);
|
||||||
|
setShowInvalidUrlDialog(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleFetchWithValidation = () => {
|
||||||
|
if (!isSpotifyUrl(url)) {
|
||||||
|
setInvalidUrl(url);
|
||||||
|
setShowInvalidUrlDialog(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onFetch();
|
||||||
|
};
|
||||||
const handleResultClick = (externalUrl: string) => {
|
const handleResultClick = (externalUrl: string) => {
|
||||||
onSearchModeChange(false);
|
onSearchModeChange(false);
|
||||||
onFetchUrl(externalUrl);
|
onFetchUrl(externalUrl);
|
||||||
@@ -210,20 +446,107 @@ export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, hist
|
|||||||
const seconds = Math.floor((ms % 60000) / 1000);
|
const seconds = Math.floor((ms % 60000) / 1000);
|
||||||
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
||||||
};
|
};
|
||||||
const hasAnyResults = searchResults && (searchResults.tracks.length > 0 ||
|
const hasAnyResults = searchResults &&
|
||||||
searchResults.albums.length > 0 ||
|
(searchResults.tracks.length > 0 ||
|
||||||
searchResults.artists.length > 0 ||
|
searchResults.albums.length > 0 ||
|
||||||
searchResults.playlists.length > 0);
|
searchResults.artists.length > 0 ||
|
||||||
|
searchResults.playlists.length > 0);
|
||||||
const getTabCount = (tab: ResultTab): number => {
|
const getTabCount = (tab: ResultTab): number => {
|
||||||
if (!searchResults)
|
if (!searchResults)
|
||||||
return 0;
|
return 0;
|
||||||
switch (tab) {
|
switch (tab) {
|
||||||
case "tracks": return searchResults.tracks.length;
|
case "tracks":
|
||||||
case "albums": return searchResults.albums.length;
|
return searchResults.tracks.length;
|
||||||
case "artists": return searchResults.artists.length;
|
case "albums":
|
||||||
case "playlists": return searchResults.playlists.length;
|
return searchResults.albums.length;
|
||||||
|
case "artists":
|
||||||
|
return searchResults.artists.length;
|
||||||
|
case "playlists":
|
||||||
|
return searchResults.playlists.length;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const sortedResults = useMemo(() => {
|
||||||
|
if (!searchResults)
|
||||||
|
return { tracks: [], albums: [], artists: [], playlists: [] };
|
||||||
|
const filterStr = resultFilter.toLowerCase();
|
||||||
|
let tracks = [...searchResults.tracks];
|
||||||
|
if (filterStr) {
|
||||||
|
tracks = tracks.filter(t => (t.name || '').toLowerCase().includes(filterStr) || (t.artists || '').toLowerCase().includes(filterStr));
|
||||||
|
}
|
||||||
|
const tSort = sortOrders.tracks;
|
||||||
|
if (tSort !== 'default') {
|
||||||
|
tracks.sort((a, b) => {
|
||||||
|
if (tSort === 'title-asc')
|
||||||
|
return (a.name || '').localeCompare(b.name || '');
|
||||||
|
if (tSort === 'title-desc')
|
||||||
|
return (b.name || '').localeCompare(a.name || '');
|
||||||
|
if (tSort === 'artist-asc')
|
||||||
|
return (a.artists || '').localeCompare(b.artists || '');
|
||||||
|
if (tSort === 'artist-desc')
|
||||||
|
return (b.artists || '').localeCompare(a.artists || '');
|
||||||
|
if (tSort === 'duration-desc')
|
||||||
|
return (b.duration_ms || 0) - (a.duration_ms || 0);
|
||||||
|
if (tSort === 'duration-asc')
|
||||||
|
return (a.duration_ms || 0) - (b.duration_ms || 0);
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let albums = [...searchResults.albums];
|
||||||
|
if (filterStr) {
|
||||||
|
albums = albums.filter(a => (a.name || '').toLowerCase().includes(filterStr) || (a.artists || '').toLowerCase().includes(filterStr));
|
||||||
|
}
|
||||||
|
const alSort = sortOrders.albums;
|
||||||
|
if (alSort !== 'default') {
|
||||||
|
albums.sort((a, b) => {
|
||||||
|
if (alSort === 'title-asc')
|
||||||
|
return (a.name || '').localeCompare(b.name || '');
|
||||||
|
if (alSort === 'title-desc')
|
||||||
|
return (b.name || '').localeCompare(a.name || '');
|
||||||
|
if (alSort === 'artist-asc')
|
||||||
|
return (a.artists || '').localeCompare(b.artists || '');
|
||||||
|
if (alSort === 'artist-desc')
|
||||||
|
return (b.artists || '').localeCompare(a.artists || '');
|
||||||
|
if (alSort === 'year-desc')
|
||||||
|
return (b.release_date || '').localeCompare(a.release_date || '');
|
||||||
|
if (alSort === 'year-asc')
|
||||||
|
return (a.release_date || '').localeCompare(b.release_date || '');
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let artists = [...searchResults.artists];
|
||||||
|
if (filterStr) {
|
||||||
|
artists = artists.filter(a => (a.name || '').toLowerCase().includes(filterStr));
|
||||||
|
}
|
||||||
|
const arSort = sortOrders.artists;
|
||||||
|
if (arSort !== 'default') {
|
||||||
|
artists.sort((a, b) => {
|
||||||
|
if (arSort === 'name-asc')
|
||||||
|
return (a.name || '').localeCompare(b.name || '');
|
||||||
|
if (arSort === 'name-desc')
|
||||||
|
return (b.name || '').localeCompare(a.name || '');
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let playlists = [...searchResults.playlists];
|
||||||
|
if (filterStr) {
|
||||||
|
playlists = playlists.filter(p => (p.name || '').toLowerCase().includes(filterStr) || (p.owner || '').toLowerCase().includes(filterStr));
|
||||||
|
}
|
||||||
|
const pSort = sortOrders.playlists;
|
||||||
|
if (pSort !== 'default') {
|
||||||
|
playlists.sort((a, b) => {
|
||||||
|
if (pSort === 'title-asc')
|
||||||
|
return (a.name || '').localeCompare(b.name || '');
|
||||||
|
if (pSort === 'title-desc')
|
||||||
|
return (b.name || '').localeCompare(a.name || '');
|
||||||
|
if (pSort === 'owner-asc')
|
||||||
|
return (a.owner || '').localeCompare(b.owner || '');
|
||||||
|
if (pSort === 'owner-desc')
|
||||||
|
return (b.owner || '').localeCompare(a.owner || '');
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { tracks, albums, artists, playlists };
|
||||||
|
}, [searchResults, sortOrders, resultFilter]);
|
||||||
const tabs: {
|
const tabs: {
|
||||||
key: ResultTab;
|
key: ResultTab;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -234,167 +557,247 @@ export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, hist
|
|||||||
{ key: "playlists", label: "Playlists" },
|
{ key: "playlists", label: "Playlists" },
|
||||||
];
|
];
|
||||||
return (<div className="space-y-4">
|
return (<div className="space-y-4">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button variant="outline" size="icon" className="shrink-0" onClick={() => onSearchModeChange(!searchMode)}>
|
<Button variant="outline" size="icon" className="shrink-0" onClick={() => onSearchModeChange(!searchMode)}>
|
||||||
{searchMode ? <Link className="h-4 w-4"/> : <Search className="h-4 w-4"/>}
|
{searchMode ? (<Link className="h-4 w-4"/>) : (<Search className="h-4 w-4"/>)}
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>{searchMode ? "Fetch Mode" : "Search Mode"}</p>
|
<p>{searchMode ? "Fetch Mode" : "Search Mode"}</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
{!searchMode ? (<>
|
{!searchMode ? (<>
|
||||||
<InputWithContext id="spotify-url" placeholder={placeholderText} value={url} onChange={(e) => onUrlChange(e.target.value)} onKeyDown={(e) => e.key === "Enter" && onFetch()} className="pr-8"/>
|
<InputWithContext id="spotify-url" placeholder={placeholderText} value={url} onChange={(e) => onUrlChange(e.target.value)} onPaste={handlePaste} onKeyDown={(e) => e.key === "Enter" && handleFetchWithValidation()} className="pr-8"/>
|
||||||
{url && (<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={() => onUrlChange("")}>
|
{url && (<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={() => onUrlChange("")}>
|
||||||
<XCircle className="h-4 w-4"/>
|
<XCircle className="h-4 w-4"/>
|
||||||
</button>)}
|
</button>)}
|
||||||
</>) : (<>
|
</>) : (<>
|
||||||
<InputWithContext id="spotify-search" placeholder={placeholderText} value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} className="pr-8"/>
|
<InputWithContext id="spotify-search" placeholder={placeholderText} value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} className="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={() => {
|
{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={() => {
|
||||||
setSearchQuery("");
|
setSearchQuery("");
|
||||||
setSearchResults(null);
|
setSearchResults(null);
|
||||||
setLastSearchedQuery("");
|
setLastSearchedQuery("");
|
||||||
|
setResultFilter("");
|
||||||
}}>
|
}}>
|
||||||
<XCircle className="h-4 w-4"/>
|
<XCircle className="h-4 w-4"/>
|
||||||
</button>)}
|
</button>)}
|
||||||
</>)}
|
</>)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!searchMode && (<>
|
{!searchMode && (<>
|
||||||
<Select value={region} onValueChange={onRegionChange}>
|
<Select value={region} onValueChange={onRegionChange}>
|
||||||
<SelectTrigger className="w-[70px] shrink-0">
|
<SelectTrigger className="w-[70px] shrink-0">
|
||||||
<SelectValue placeholder="Region"/>
|
<SelectValue placeholder="Region"/>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="max-h-[300px]">
|
<SelectContent className="max-h-[300px]">
|
||||||
{REGIONS.map((r) => (<SelectItem key={r} value={r} textValue={r}>
|
{REGIONS.map((r) => (<SelectItem key={r} value={r} textValue={r}>
|
||||||
{r} <span className="text-muted-foreground">({getRegionName(r)})</span>
|
{r}{" "}
|
||||||
</SelectItem>))}
|
<span className="text-muted-foreground">
|
||||||
</SelectContent>
|
({getRegionName(r)})
|
||||||
</Select>
|
</span>
|
||||||
<Button onClick={onFetch} disabled={loading}>
|
</SelectItem>))}
|
||||||
{loading ? (<>
|
</SelectContent>
|
||||||
<Spinner />
|
</Select>
|
||||||
Fetching...
|
<Button onClick={handleFetchWithValidation} disabled={loading}>
|
||||||
</>) : (<>
|
{loading ? (<>
|
||||||
<CloudDownload className="h-4 w-4"/>
|
<Spinner />
|
||||||
Fetch
|
Fetching...
|
||||||
</>)}
|
</>) : (<>
|
||||||
</Button>
|
<CloudDownload className="h-4 w-4"/>
|
||||||
</>)}
|
Fetch
|
||||||
</div>
|
</>)}
|
||||||
|
</Button>
|
||||||
|
</>)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{!searchMode && !hasResult && (<FetchHistory history={history} onSelect={onHistorySelect} onRemove={onHistoryRemove}/>)}
|
{!searchMode && !hasResult && (<FetchHistory history={history} onSelect={onHistorySelect} onRemove={onHistoryRemove}/>)}
|
||||||
|
|
||||||
{searchMode && (<div className="space-y-4">
|
{searchMode && (<div className="space-y-4">
|
||||||
{!searchQuery && !searchResults && recentSearches.length > 0 && (<div className="space-y-2">
|
{!searchQuery && !searchResults && recentSearches.length > 0 && (<div className="space-y-2">
|
||||||
<p className="text-sm text-muted-foreground">Recent Searches</p>
|
<p className="text-sm text-muted-foreground">Recent Searches</p>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{recentSearches.map((query) => (<div key={query} className="group relative flex items-center px-3 py-1.5 bg-muted hover:bg-accent rounded-full text-sm cursor-pointer transition-colors" onClick={() => setSearchQuery(query)}>
|
{recentSearches.map((query) => (<div key={query} className="group relative flex items-center px-3 py-1.5 bg-muted hover:bg-accent rounded-full text-sm cursor-pointer transition-colors" onClick={() => setSearchQuery(query)}>
|
||||||
<span>{query}</span>
|
<span>{query}</span>
|
||||||
<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) => {
|
<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();
|
e.stopPropagation();
|
||||||
removeRecentSearch(query);
|
removeRecentSearch(query);
|
||||||
}}>
|
}}>
|
||||||
<X className="h-3 w-3 text-red-900" strokeWidth={3}/>
|
<X className="h-3 w-3 text-red-900" strokeWidth={3}/>
|
||||||
</button>
|
</button>
|
||||||
</div>))}
|
</div>))}
|
||||||
</div>
|
</div>
|
||||||
</div>)}
|
</div>)}
|
||||||
|
|
||||||
{isSearching && (<div className="flex items-center justify-center py-8">
|
{isSearching && (<div className="flex items-center justify-center py-8">
|
||||||
<Spinner />
|
<Spinner />
|
||||||
<span className="ml-2 text-muted-foreground">Searching...</span>
|
<span className="ml-2 text-muted-foreground">Searching...</span>
|
||||||
</div>)}
|
</div>)}
|
||||||
|
|
||||||
{!isSearching && searchQuery && !hasAnyResults && (<div className="text-center py-8 text-muted-foreground">
|
{!isSearching && searchQuery && !hasAnyResults && (<div className="text-center py-8 text-muted-foreground">
|
||||||
No results found for "{searchQuery}"
|
No results found for "{searchQuery}"
|
||||||
</div>)}
|
</div>)}
|
||||||
|
|
||||||
{!isSearching && hasAnyResults && (<>
|
{!isSearching && hasAnyResults && (<>
|
||||||
<div className="flex gap-1 border-b">
|
<div className="flex gap-1 border-b mb-4">
|
||||||
{tabs.map((tab) => {
|
{tabs.map((tab) => {
|
||||||
const count = getTabCount(tab.key);
|
const count = getTabCount(tab.key);
|
||||||
if (count === 0)
|
if (count === 0)
|
||||||
return null;
|
return null;
|
||||||
return (<button key={tab.key} type="button" onClick={() => setActiveTab(tab.key)} className={cn("px-4 py-2 text-sm font-medium transition-colors cursor-pointer border-b-2 -mb-px", activeTab === tab.key
|
return (<button key={tab.key} type="button" onClick={() => setActiveTab(tab.key)} className={cn("px-4 py-2 text-sm font-medium transition-colors cursor-pointer border-b-2 -mb-px", activeTab === tab.key
|
||||||
? "border-primary text-foreground"
|
? "border-primary text-foreground"
|
||||||
: "border-transparent text-muted-foreground hover:text-foreground")}>
|
: "border-transparent text-muted-foreground hover:text-foreground")}>
|
||||||
{tab.label} ({count})
|
{tab.label} ({count})
|
||||||
</button>);
|
</button>);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="flex gap-2 mb-4">
|
||||||
{activeTab === "tracks" &&
|
<div className="relative flex-1">
|
||||||
searchResults?.tracks.map((track) => (<button key={track.id} type="button" className="flex items-center gap-3 p-3 rounded-lg bg-card hover:bg-accent border cursor-pointer text-left transition-colors" onClick={() => handleResultClick(track.external_urls)}>
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground"/>
|
||||||
{track.images ? (<img src={track.images} alt="" className="w-12 h-12 rounded object-cover shrink-0"/>) : (<div className="w-12 h-12 rounded bg-muted shrink-0"/>)}
|
<Input placeholder={`Search ${activeTab}...`} value={resultFilter} onChange={(e) => setResultFilter(e.target.value)} className="pl-10 pr-8"/>
|
||||||
<div className="flex-1 min-w-0">
|
{resultFilter && (<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={() => setResultFilter("")}>
|
||||||
<div className="flex items-center gap-1.5 min-w-0">
|
<XCircle className="h-4 w-4"/>
|
||||||
<p className="font-medium truncate">{track.name}</p>
|
</button>)}
|
||||||
{track.is_explicit && (<span className="flex items-center justify-center min-w-[16px] h-[16px] rounded bg-red-600 text-[10px] font-bold text-white leading-none shrink-0" title="Explicit">
|
</div>
|
||||||
E
|
<Select value={sortOrders[activeTab]} onValueChange={(val) => setSortOrders(prev => ({ ...prev, [activeTab]: val }))}>
|
||||||
</span>)}
|
<SelectTrigger className="w-[170px] bg-background gap-1.5">
|
||||||
</div>
|
<ArrowUpDown className="h-4 w-4 text-muted-foreground"/>
|
||||||
<p className="text-sm text-muted-foreground truncate">
|
<SelectValue placeholder="Sort by"/>
|
||||||
{track.artists}
|
</SelectTrigger>
|
||||||
</p>
|
<SelectContent>
|
||||||
</div>
|
<SelectItem value="default">Default</SelectItem>
|
||||||
<span className="text-sm text-muted-foreground shrink-0">
|
{activeTab === 'tracks' && (<>
|
||||||
{formatDuration(track.duration_ms || 0)}
|
<SelectItem value="title-asc">Title (A-Z)</SelectItem>
|
||||||
</span>
|
<SelectItem value="title-desc">Title (Z-A)</SelectItem>
|
||||||
</button>))}
|
<SelectItem value="artist-asc">Artist (A-Z)</SelectItem>
|
||||||
|
<SelectItem value="artist-desc">Artist (Z-A)</SelectItem>
|
||||||
{activeTab === "albums" &&
|
<SelectItem value="duration-desc">Duration (Longest)</SelectItem>
|
||||||
searchResults?.albums.map((album) => (<button key={album.id} type="button" className="flex items-center gap-3 p-3 rounded-lg bg-card hover:bg-accent border cursor-pointer text-left transition-colors" onClick={() => handleResultClick(album.external_urls)}>
|
<SelectItem value="duration-asc">Duration (Shortest)</SelectItem>
|
||||||
{album.images ? (<img src={album.images} alt="" className="w-12 h-12 rounded object-cover shrink-0"/>) : (<div className="w-12 h-12 rounded bg-muted shrink-0"/>)}
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className="font-medium truncate">{album.name}</p>
|
|
||||||
<p className="text-sm text-muted-foreground truncate">
|
|
||||||
{album.artists}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<span className="text-sm text-muted-foreground shrink-0">
|
|
||||||
{album.release_date || ""}
|
|
||||||
</span>
|
|
||||||
</button>))}
|
|
||||||
|
|
||||||
{activeTab === "artists" &&
|
|
||||||
searchResults?.artists.map((artist) => (<button key={artist.id} type="button" className="flex items-center gap-3 p-3 rounded-lg bg-card hover:bg-accent border cursor-pointer text-left transition-colors" onClick={() => handleResultClick(artist.external_urls)}>
|
|
||||||
{artist.images ? (<img src={artist.images} alt="" className="w-12 h-12 rounded-full object-cover shrink-0"/>) : (<div className="w-12 h-12 rounded-full bg-muted shrink-0"/>)}
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className="font-medium truncate">{artist.name}</p>
|
|
||||||
<p className="text-sm text-muted-foreground">Artist</p>
|
|
||||||
</div>
|
|
||||||
</button>))}
|
|
||||||
|
|
||||||
{activeTab === "playlists" &&
|
|
||||||
searchResults?.playlists.map((playlist) => (<button key={playlist.id} type="button" className="flex items-center gap-3 p-3 rounded-lg bg-card hover:bg-accent border cursor-pointer text-left transition-colors" onClick={() => handleResultClick(playlist.external_urls)}>
|
|
||||||
{playlist.images ? (<img src={playlist.images} alt="" className="w-12 h-12 rounded object-cover shrink-0"/>) : (<div className="w-12 h-12 rounded bg-muted shrink-0"/>)}
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className="font-medium truncate">{playlist.name}</p>
|
|
||||||
<p className="text-sm text-muted-foreground truncate">
|
|
||||||
{playlist.owner || ""}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</button>))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{hasMore[activeTab] && (<div className="flex justify-center pt-2">
|
|
||||||
<Button variant="outline" onClick={handleLoadMore} disabled={isLoadingMore}>
|
|
||||||
{isLoadingMore ? (<>
|
|
||||||
<Spinner />
|
|
||||||
Loading...
|
|
||||||
</>) : (<>
|
|
||||||
<ChevronDown className="h-4 w-4"/>
|
|
||||||
Load More
|
|
||||||
</>)}
|
|
||||||
</Button>
|
|
||||||
</div>)}
|
|
||||||
</>)}
|
</>)}
|
||||||
|
{activeTab === 'albums' && (<>
|
||||||
|
<SelectItem value="title-asc">Title (A-Z)</SelectItem>
|
||||||
|
<SelectItem value="title-desc">Title (Z-A)</SelectItem>
|
||||||
|
<SelectItem value="artist-asc">Artist (A-Z)</SelectItem>
|
||||||
|
<SelectItem value="artist-desc">Artist (Z-A)</SelectItem>
|
||||||
|
<SelectItem value="year-desc">Year (Newest)</SelectItem>
|
||||||
|
<SelectItem value="year-asc">Year (Oldest)</SelectItem>
|
||||||
|
</>)}
|
||||||
|
{activeTab === 'artists' && (<>
|
||||||
|
<SelectItem value="name-asc">Name (A-Z)</SelectItem>
|
||||||
|
<SelectItem value="name-desc">Name (Z-A)</SelectItem>
|
||||||
|
</>)}
|
||||||
|
{activeTab === 'playlists' && (<>
|
||||||
|
<SelectItem value="title-asc">Title (A-Z)</SelectItem>
|
||||||
|
<SelectItem value="title-desc">Title (Z-A)</SelectItem>
|
||||||
|
<SelectItem value="owner-asc">Owner (A-Z)</SelectItem>
|
||||||
|
<SelectItem value="owner-desc">Owner (Z-A)</SelectItem>
|
||||||
|
</>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
{activeTab === "tracks" &&
|
||||||
|
sortedResults.tracks.map((track) => (<button key={track.id} type="button" className="flex items-center gap-3 p-3 rounded-lg bg-card hover:bg-accent border cursor-pointer text-left transition-colors" onClick={() => handleResultClick(track.external_urls)}>
|
||||||
|
{track.images ? (<img src={track.images} alt="" className="w-12 h-12 rounded object-cover shrink-0"/>) : (<div className="w-12 h-12 rounded bg-muted shrink-0"/>)}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
|
<p className="font-medium truncate">{track.name}</p>
|
||||||
|
{track.is_explicit && (<span className="flex items-center justify-center min-w-[16px] h-[16px] rounded bg-red-600 text-[10px] font-bold text-white leading-none shrink-0" title="Explicit">
|
||||||
|
E
|
||||||
|
</span>)}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground truncate">
|
||||||
|
{track.artists}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-muted-foreground shrink-0">
|
||||||
|
{formatDuration(track.duration_ms || 0)}
|
||||||
|
</span>
|
||||||
|
</button>))}
|
||||||
|
|
||||||
|
{activeTab === "albums" &&
|
||||||
|
sortedResults.albums.map((album) => (<button key={album.id} type="button" className="flex items-center gap-3 p-3 rounded-lg bg-card hover:bg-accent border cursor-pointer text-left transition-colors" onClick={() => handleResultClick(album.external_urls)}>
|
||||||
|
{album.images ? (<img src={album.images} alt="" className="w-12 h-12 rounded object-cover shrink-0"/>) : (<div className="w-12 h-12 rounded bg-muted shrink-0"/>)}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-medium truncate">{album.name}</p>
|
||||||
|
<p className="text-sm text-muted-foreground truncate">
|
||||||
|
{album.artists}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-muted-foreground shrink-0">
|
||||||
|
{album.release_date || ""}
|
||||||
|
</span>
|
||||||
|
</button>))}
|
||||||
|
|
||||||
|
{activeTab === "artists" &&
|
||||||
|
sortedResults.artists.map((artist) => (<button key={artist.id} type="button" className="flex items-center gap-3 p-3 rounded-lg bg-card hover:bg-accent border cursor-pointer text-left transition-colors" onClick={() => handleResultClick(artist.external_urls)}>
|
||||||
|
{artist.images ? (<img src={artist.images} alt="" className="w-12 h-12 rounded-full object-cover shrink-0"/>) : (<div className="w-12 h-12 rounded-full bg-muted shrink-0"/>)}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-medium truncate">{artist.name}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">Artist</p>
|
||||||
|
</div>
|
||||||
|
</button>))}
|
||||||
|
|
||||||
|
{activeTab === "playlists" &&
|
||||||
|
sortedResults.playlists.map((playlist) => (<button key={playlist.id} type="button" className="flex items-center gap-3 p-3 rounded-lg bg-card hover:bg-accent border cursor-pointer text-left transition-colors" onClick={() => handleResultClick(playlist.external_urls)}>
|
||||||
|
{playlist.images ? (<img src={playlist.images} alt="" className="w-12 h-12 rounded object-cover shrink-0"/>) : (<div className="w-12 h-12 rounded bg-muted shrink-0"/>)}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-medium truncate">{playlist.name}</p>
|
||||||
|
<p className="text-sm text-muted-foreground truncate">
|
||||||
|
{playlist.owner || ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</button>))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasMore[activeTab] && (<div className="flex justify-center pt-2">
|
||||||
|
<Button variant="outline" onClick={handleLoadMore} disabled={isLoadingMore}>
|
||||||
|
{isLoadingMore ? (<>
|
||||||
|
<Spinner />
|
||||||
|
Loading...
|
||||||
|
</>) : (<>
|
||||||
|
<ChevronDown className="h-4 w-4"/>
|
||||||
|
Load More
|
||||||
|
</>)}
|
||||||
|
</Button>
|
||||||
</div>)}
|
</div>)}
|
||||||
</div>);
|
</>)}
|
||||||
|
</div>)}
|
||||||
|
|
||||||
|
<Dialog open={showInvalidUrlDialog} onOpenChange={setShowInvalidUrlDialog}>
|
||||||
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Invalid URL</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Only Spotify links are allowed in Fetch mode.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{invalidUrl && (<div className="p-3 bg-muted rounded-md border text-xs font-mono break-all opacity-70">
|
||||||
|
{invalidUrl}
|
||||||
|
</div>)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => {
|
||||||
|
setShowInvalidUrlDialog(false);
|
||||||
|
setInvalidUrl("");
|
||||||
|
}}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => {
|
||||||
|
onSearchModeChange(true);
|
||||||
|
setShowInvalidUrlDialog(false);
|
||||||
|
setInvalidUrl("");
|
||||||
|
}}>
|
||||||
|
Switch to Search
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,13 +5,14 @@ import { InputWithContext } from "@/components/ui/input-with-context";
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
|
||||||
import { FolderOpen, Save, RotateCcw, Info, ArrowRight, Settings, FolderCog, } from "lucide-react";
|
import { FolderOpen, Save, RotateCcw, Info, ArrowRight, MonitorCog, FolderCog, Router, FolderLock } from "lucide-react";
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
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 { 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 { themes, applyTheme } from "@/lib/themes";
|
||||||
import { SelectFolder } from "../../wailsjs/go/main/App";
|
import { SelectFolder, OpenConfigFolder } from "../../wailsjs/go/main/App";
|
||||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||||
|
import { ApiStatusTab } from "./ApiStatusTab";
|
||||||
const TidalIcon = ({ className }: {
|
const TidalIcon = ({ className }: {
|
||||||
className?: string;
|
className?: string;
|
||||||
}) => (<svg viewBox="0 0 24 24" className={`inline-block w-[1.1em] h-[1.1em] mr-2 ${className || "fill-muted-foreground"}`}>
|
}) => (<svg viewBox="0 0 24 24" className={`inline-block w-[1.1em] h-[1.1em] mr-2 ${className || "fill-muted-foreground"}`}>
|
||||||
@@ -118,17 +119,28 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
|||||||
const handleTidalQualityChange = async (value: "LOSSLESS" | "HI_RES_LOSSLESS") => {
|
const handleTidalQualityChange = async (value: "LOSSLESS" | "HI_RES_LOSSLESS") => {
|
||||||
setTempSettings((prev) => ({ ...prev, tidalQuality: value }));
|
setTempSettings((prev) => ({ ...prev, tidalQuality: value }));
|
||||||
};
|
};
|
||||||
const handleQobuzQualityChange = (value: "6" | "7") => {
|
const handleQobuzQualityChange = (value: "6" | "7" | "27") => {
|
||||||
setTempSettings((prev) => ({ ...prev, qobuzQuality: value }));
|
setTempSettings((prev) => ({ ...prev, qobuzQuality: value }));
|
||||||
};
|
};
|
||||||
const handleAutoQualityChange = async (value: "16" | "24") => {
|
const handleAutoQualityChange = async (value: "16" | "24") => {
|
||||||
setTempSettings((prev) => ({ ...prev, autoQuality: value }));
|
setTempSettings((prev) => ({ ...prev, autoQuality: value }));
|
||||||
};
|
};
|
||||||
const [activeTab, setActiveTab] = useState<"general" | "files">("general");
|
const [activeTab, setActiveTab] = useState<"general" | "files" | "api">("general");
|
||||||
return (<div className="space-y-4 h-full flex flex-col">
|
return (<div className="space-y-4 h-full flex flex-col">
|
||||||
<div className="flex items-center justify-between shrink-0">
|
<div className="flex items-center justify-between shrink-0">
|
||||||
<h1 className="text-2xl font-bold">Settings</h1>
|
<h1 className="text-2xl font-bold">Settings</h1>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" onClick={async () => {
|
||||||
|
try {
|
||||||
|
await OpenConfigFolder();
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
toast.error(`Failed to open config folder: ${e}`);
|
||||||
|
}
|
||||||
|
}} className="gap-1.5">
|
||||||
|
<FolderLock className="h-4 w-4"/>
|
||||||
|
Open Config Folder
|
||||||
|
</Button>
|
||||||
<Button variant="outline" onClick={() => setShowResetConfirm(true)} className="gap-1.5">
|
<Button variant="outline" onClick={() => setShowResetConfirm(true)} className="gap-1.5">
|
||||||
<RotateCcw className="h-4 w-4"/>
|
<RotateCcw className="h-4 w-4"/>
|
||||||
Reset to Default
|
Reset to Default
|
||||||
@@ -142,13 +154,17 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
|||||||
|
|
||||||
<div className="flex gap-2 border-b shrink-0">
|
<div className="flex gap-2 border-b shrink-0">
|
||||||
<Button variant={activeTab === "general" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("general")} className="rounded-b-none gap-2">
|
<Button variant={activeTab === "general" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("general")} className="rounded-b-none gap-2">
|
||||||
<Settings className="h-4 w-4"/>
|
<MonitorCog className="h-4 w-4"/>
|
||||||
General
|
General
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant={activeTab === "files" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("files")} className="rounded-b-none gap-2">
|
<Button variant={activeTab === "files" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("files")} className="rounded-b-none gap-2">
|
||||||
<FolderCog className="h-4 w-4"/>
|
<FolderCog className="h-4 w-4"/>
|
||||||
File Management
|
File Management
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button variant={activeTab === "api" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("api")} className="rounded-b-none gap-2">
|
||||||
|
<Router className="h-4 w-4"/>
|
||||||
|
Status
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto pt-4">
|
<div className="flex-1 overflow-y-auto pt-4">
|
||||||
@@ -234,7 +250,7 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="downloader">Source</Label>
|
<Label htmlFor="downloader">Source</Label>
|
||||||
<div className="flex gap-2 flex-wrap">
|
<div className="flex gap-2 flex-wrap">
|
||||||
<Select value={tempSettings.downloader} onValueChange={(value: "auto" | "tidal" | "qobuz" | "amazon") => setTempSettings((prev) => ({
|
<Select value={tempSettings.downloader} onValueChange={(value: any) => setTempSettings((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
downloader: value,
|
downloader: value,
|
||||||
}))}>
|
}))}>
|
||||||
@@ -261,6 +277,7 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
|||||||
Amazon Music
|
Amazon Music
|
||||||
</span>
|
</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
@@ -273,50 +290,7 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
|||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="tidal-qobuz">
|
|
||||||
<span className="flex items-center gap-1.5">
|
|
||||||
<TidalIcon className="fill-current"/>
|
|
||||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
|
||||||
<QobuzIcon className="fill-current"/>
|
|
||||||
</span>
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="tidal-amazon">
|
|
||||||
<span className="flex items-center gap-1.5">
|
|
||||||
<TidalIcon className="fill-current"/>
|
|
||||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
|
||||||
<AmazonIcon className="fill-current"/>
|
|
||||||
</span>
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="qobuz-tidal">
|
|
||||||
<span className="flex items-center gap-1.5">
|
|
||||||
<QobuzIcon className="fill-current"/>
|
|
||||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
|
||||||
<TidalIcon className="fill-current"/>
|
|
||||||
</span>
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="qobuz-amazon">
|
|
||||||
<span className="flex items-center gap-1.5">
|
|
||||||
<QobuzIcon className="fill-current"/>
|
|
||||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
|
||||||
<AmazonIcon className="fill-current"/>
|
|
||||||
</span>
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="amazon-tidal">
|
|
||||||
<span className="flex items-center gap-1.5">
|
|
||||||
<AmazonIcon className="fill-current"/>
|
|
||||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
|
||||||
<TidalIcon className="fill-current"/>
|
|
||||||
</span>
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="amazon-qobuz">
|
|
||||||
<span className="flex items-center gap-1.5">
|
|
||||||
<AmazonIcon className="fill-current"/>
|
|
||||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
|
||||||
<QobuzIcon className="fill-current"/>
|
|
||||||
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
|
||||||
<TidalIcon className="fill-current"/>
|
|
||||||
</span>
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="tidal-qobuz-amazon">
|
<SelectItem value="tidal-qobuz-amazon">
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
<TidalIcon className="fill-current"/>
|
<TidalIcon className="fill-current"/>
|
||||||
@@ -371,6 +345,50 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
|||||||
<TidalIcon className="fill-current"/>
|
<TidalIcon className="fill-current"/>
|
||||||
</span>
|
</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
|
||||||
|
|
||||||
|
<SelectItem value="tidal-qobuz">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<TidalIcon className="fill-current"/>
|
||||||
|
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||||
|
<QobuzIcon className="fill-current"/>
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="tidal-amazon">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<TidalIcon className="fill-current"/>
|
||||||
|
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||||
|
<AmazonIcon className="fill-current"/>
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="qobuz-tidal">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<QobuzIcon className="fill-current"/>
|
||||||
|
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||||
|
<TidalIcon className="fill-current"/>
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="qobuz-amazon">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<QobuzIcon className="fill-current"/>
|
||||||
|
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||||
|
<AmazonIcon className="fill-current"/>
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="amazon-tidal">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<AmazonIcon className="fill-current"/>
|
||||||
|
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||||
|
<TidalIcon className="fill-current"/>
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="amazon-qobuz">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<AmazonIcon className="fill-current"/>
|
||||||
|
<ArrowRight className="h-3 w-3 text-muted-foreground"/>
|
||||||
|
<QobuzIcon className="fill-current"/>
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
@@ -403,19 +421,20 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
|||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="6">16-bit/44.1kHz</SelectItem>
|
<SelectItem value="6">16-bit/44.1kHz</SelectItem>
|
||||||
<SelectItem value="7">24-bit/48kHz</SelectItem>
|
<SelectItem value="27">24-bit/48kHz - 192kHz</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>)}
|
</Select>)}
|
||||||
|
|
||||||
{tempSettings.downloader === "amazon" && (<div className="h-9 px-3 flex items-center text-sm font-medium border border-input rounded-md bg-muted/30 text-muted-foreground whitespace-nowrap cursor-default">
|
{tempSettings.downloader === "amazon" && (<div className="h-9 px-3 flex items-center text-sm font-medium border border-input rounded-md bg-muted/30 text-muted-foreground whitespace-nowrap cursor-default">
|
||||||
16-bit - 24-bit/44.1kHz - 192kHz
|
16-bit - 24-bit/44.1kHz - 192kHz
|
||||||
</div>)}
|
</div>)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{((tempSettings.downloader === "tidal" &&
|
{((tempSettings.downloader === "tidal" &&
|
||||||
tempSettings.tidalQuality === "HI_RES_LOSSLESS") ||
|
tempSettings.tidalQuality === "HI_RES_LOSSLESS") ||
|
||||||
(tempSettings.downloader === "qobuz" &&
|
(tempSettings.downloader === "qobuz" &&
|
||||||
tempSettings.qobuzQuality === "7") ||
|
tempSettings.qobuzQuality === "27") ||
|
||||||
(tempSettings.downloader === "auto" &&
|
(tempSettings.downloader === "auto" &&
|
||||||
tempSettings.autoQuality === "24")) && (<div className="flex items-center gap-3 pt-2">
|
tempSettings.autoQuality === "24")) && (<div className="flex items-center gap-3 pt-2">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -451,6 +470,24 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
|||||||
Embed Max Quality Cover
|
Embed Max Quality Cover
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Switch id="embed-genre" checked={tempSettings.embedGenre} onCheckedChange={(checked) => setTempSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
embedGenre: checked,
|
||||||
|
}))}/>
|
||||||
|
<Label htmlFor="embed-genre" className="cursor-pointer text-sm font-normal">
|
||||||
|
Embed Genre
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
{tempSettings.embedGenre && (<div className="flex items-center gap-3">
|
||||||
|
<Switch id="use-single-genre" checked={tempSettings.useSingleGenre} onCheckedChange={(checked) => setTempSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
useSingleGenre: checked,
|
||||||
|
}))}/>
|
||||||
|
<Label htmlFor="use-single-genre" className="text-sm cursor-pointer font-normal">
|
||||||
|
Use Single Genre
|
||||||
|
</Label>
|
||||||
|
</div>)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>)}
|
</div>)}
|
||||||
@@ -501,10 +538,14 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
|||||||
Preview:{" "}
|
Preview:{" "}
|
||||||
<span className="font-mono">
|
<span className="font-mono">
|
||||||
{tempSettings.folderTemplate
|
{tempSettings.folderTemplate
|
||||||
.replace(/\{artist\}/g, "Kendrick Lamar, SZA")
|
.replace(/\{artist\}/g, tempSettings.separator === "comma" ? "Kendrick Lamar, SZA" : "Kendrick Lamar; SZA")
|
||||||
.replace(/\{album\}/g, "Black Panther")
|
.replace(/\{album\}/g, "Black Panther")
|
||||||
.replace(/\{album_artist\}/g, "Kendrick Lamar")
|
.replace(/\{album_artist\}/g, "Kendrick Lamar")
|
||||||
.replace(/\{year\}/g, "2018")}
|
.replace(/\{title\}/g, "All The Stars")
|
||||||
|
.replace(/\{track\}/g, "01")
|
||||||
|
.replace(/\{disc\}/g, "1")
|
||||||
|
.replace(/\{year\}/g, "2018")
|
||||||
|
.replace(/\{date\}/g, "2018-02-09")}
|
||||||
/
|
/
|
||||||
</span>
|
</span>
|
||||||
</p>)}
|
</p>)}
|
||||||
@@ -539,6 +580,8 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
|||||||
Use First Artist Only
|
Use First Artist Only
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -581,21 +624,44 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin
|
|||||||
filenameTemplate: e.target.value,
|
filenameTemplate: e.target.value,
|
||||||
}))} placeholder="{track}. {title}" className="h-9 text-sm flex-1"/>)}
|
}))} placeholder="{track}. {title}" className="h-9 text-sm flex-1"/>)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-2 pt-2">
|
||||||
|
<Label className="text-sm">Separator</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Select value={tempSettings.separator} onValueChange={(value: "comma" | "semicolon") => setTempSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
separator: value,
|
||||||
|
}))}>
|
||||||
|
<SelectTrigger className="h-9 w-fit">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="comma">Comma (,)</SelectItem>
|
||||||
|
<SelectItem value="semicolon">Semicolon (;)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{tempSettings.filenameTemplate && (<p className="text-xs text-muted-foreground">
|
{tempSettings.filenameTemplate && (<p className="text-xs text-muted-foreground">
|
||||||
Preview:{" "}
|
Preview:{" "}
|
||||||
<span className="font-mono">
|
<span className="font-mono">
|
||||||
{tempSettings.filenameTemplate
|
{tempSettings.filenameTemplate
|
||||||
.replace(/\{artist\}/g, "Kendrick Lamar, SZA")
|
.replace(/\{artist\}/g, tempSettings.separator === "comma" ? "Kendrick Lamar, SZA" : "Kendrick Lamar; SZA")
|
||||||
.replace(/\{album_artist\}/g, "Kendrick Lamar")
|
.replace(/\{album_artist\}/g, "Kendrick Lamar")
|
||||||
|
.replace(/\{album\}/g, "Black Panther")
|
||||||
.replace(/\{title\}/g, "All The Stars")
|
.replace(/\{title\}/g, "All The Stars")
|
||||||
.replace(/\{track\}/g, "01")
|
.replace(/\{track\}/g, "01")
|
||||||
.replace(/\{disc\}/g, "1")
|
.replace(/\{disc\}/g, "1")
|
||||||
.replace(/\{year\}/g, "2018")}
|
.replace(/\{year\}/g, "2018")
|
||||||
|
.replace(/\{date\}/g, "2018-02-09")}
|
||||||
.flac
|
.flac
|
||||||
</span>
|
</span>
|
||||||
</p>)}
|
</p>)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>)}
|
</div>)}
|
||||||
|
|
||||||
|
{activeTab === "api" && (<ApiStatusTab />)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dialog open={showResetConfirm} onOpenChange={setShowResetConfirm}>
|
<Dialog open={showResetConfirm} onOpenChange={setShowResetConfirm}>
|
||||||
|
|||||||
+186
-123
@@ -1,137 +1,200 @@
|
|||||||
|
import { useRef, useState, type RefObject } from "react";
|
||||||
import { HomeIcon } from "@/components/ui/home";
|
import { HomeIcon } from "@/components/ui/home";
|
||||||
import { HistoryIcon } from "@/components/ui/history-icon";
|
import { HistoryIcon } from "@/components/ui/history-icon";
|
||||||
import { SettingsIcon } from "@/components/ui/settings";
|
import { SettingsIcon } from "@/components/ui/settings";
|
||||||
import { ActivityIcon } from "@/components/ui/activity";
|
import { ActivityIcon, type ActivityIconHandle } from "@/components/ui/activity";
|
||||||
import { TerminalIcon } from "@/components/ui/terminal";
|
import { TerminalIcon } from "@/components/ui/terminal";
|
||||||
import { FileMusicIcon } from "@/components/ui/file-music";
|
import { FileMusicIcon, type FileMusicIconHandle } from "@/components/ui/file-music";
|
||||||
import { FilePenIcon } from "@/components/ui/file-pen";
|
import { FilePenIcon, type FilePenIconHandle } from "@/components/ui/file-pen";
|
||||||
import { CoffeeIcon } from "@/components/ui/coffee";
|
import { CoffeeIcon } from "@/components/ui/coffee";
|
||||||
import { BadgeAlertIcon } from "@/components/ui/badge-alert";
|
import { BadgeAlertIcon } from "@/components/ui/badge-alert";
|
||||||
|
import { GithubIcon } from "@/components/ui/github";
|
||||||
|
import { BlocksIcon } from "@/components/ui/blocks-icon";
|
||||||
|
import { AudioLinesIcon, type AudioLinesIconHandle } from "@/components/ui/audio-lines";
|
||||||
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { openExternal } from "@/lib/utils";
|
import { openExternal } from "@/lib/utils";
|
||||||
import BmcLogo from "@/assets/bmc-logo-side.svg";
|
export type PageType = "main" | "settings" | "debug" | "audio-analysis" | "audio-converter" | "audio-resampler" | "file-manager" | "about" | "history";
|
||||||
import BmcLogoWhite from "@/assets/bmc-logo-side-white.svg";
|
|
||||||
import KofiLogo from "@/assets/kofi_symbol.svg";
|
|
||||||
export type PageType = "main" | "settings" | "debug" | "audio-analysis" | "audio-converter" | "file-manager" | "about" | "history";
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
currentPage: PageType;
|
currentPage: PageType;
|
||||||
onPageChange: (page: PageType) => void;
|
onPageChange: (page: PageType) => void;
|
||||||
}
|
}
|
||||||
export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
interface AnimatedIconHandle {
|
||||||
return (<div className="fixed left-0 top-0 h-full w-14 bg-card border-r border-border flex flex-col items-center py-14 z-30">
|
startAnimation: () => void;
|
||||||
<div className="flex flex-col gap-2 flex-1">
|
stopAnimation: () => void;
|
||||||
|
}
|
||||||
<Tooltip delayDuration={0}>
|
export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
||||||
<TooltipTrigger asChild>
|
const [isIssuesDialogOpen, setIsIssuesDialogOpen] = useState(false);
|
||||||
<Button variant={currentPage === "main" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "main" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("main")}>
|
const [hasIssueAgreement, setHasIssueAgreement] = useState(false);
|
||||||
<HomeIcon size={20}/>
|
const analyzerIconRef = useRef<ActivityIconHandle>(null);
|
||||||
</Button>
|
const resamplerIconRef = useRef<AudioLinesIconHandle>(null);
|
||||||
</TooltipTrigger>
|
const converterIconRef = useRef<FileMusicIconHandle>(null);
|
||||||
<TooltipContent side="right">
|
const fileManagerIconRef = useRef<FilePenIconHandle>(null);
|
||||||
<p>Home</p>
|
const handleIssuesDialogChange = (open: boolean) => {
|
||||||
</TooltipContent>
|
setIsIssuesDialogOpen(open);
|
||||||
</Tooltip>
|
if (!open) {
|
||||||
|
setHasIssueAgreement(false);
|
||||||
<Tooltip delayDuration={0}>
|
}
|
||||||
<TooltipTrigger asChild>
|
};
|
||||||
<Button variant={currentPage === "history" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "history" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("history")}>
|
const handleOpenIssues = () => {
|
||||||
<HistoryIcon size={20}/>
|
openExternal("https://github.com/afkarxyz/SpotiFLAC/issues");
|
||||||
</Button>
|
handleIssuesDialogChange(false);
|
||||||
</TooltipTrigger>
|
};
|
||||||
<TooltipContent side="right">
|
const getAnimatedItemHandlers = <T extends AnimatedIconHandle>(iconRef: RefObject<T | null>) => ({
|
||||||
<p>History</p>
|
onMouseEnter: () => iconRef.current?.startAnimation(),
|
||||||
</TooltipContent>
|
onMouseLeave: () => iconRef.current?.stopAnimation(),
|
||||||
</Tooltip>
|
onFocus: () => iconRef.current?.startAnimation(),
|
||||||
|
onBlur: () => iconRef.current?.stopAnimation(),
|
||||||
<Tooltip delayDuration={0}>
|
});
|
||||||
<TooltipTrigger asChild>
|
return (<div className="fixed left-0 top-0 h-full w-14 bg-card border-r border-border flex flex-col items-center py-14 z-30">
|
||||||
<Button variant={currentPage === "audio-analysis" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "audio-analysis" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("audio-analysis")}>
|
<div className="flex flex-col gap-2 flex-1">
|
||||||
<ActivityIcon size={20}/>
|
<Tooltip delayDuration={0}>
|
||||||
</Button>
|
<TooltipTrigger asChild>
|
||||||
</TooltipTrigger>
|
<Button variant={currentPage === "main" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "main" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("main")}>
|
||||||
<TooltipContent side="right">
|
<HomeIcon size={20}/>
|
||||||
<p>Audio Quality Analyzer</p>
|
</Button>
|
||||||
</TooltipContent>
|
</TooltipTrigger>
|
||||||
</Tooltip>
|
<TooltipContent side="right">
|
||||||
|
<p>Home</p>
|
||||||
<Tooltip delayDuration={0}>
|
</TooltipContent>
|
||||||
<TooltipTrigger asChild>
|
</Tooltip>
|
||||||
<Button variant={currentPage === "audio-converter" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "audio-converter" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("audio-converter")}>
|
|
||||||
<FileMusicIcon size={20}/>
|
<Tooltip delayDuration={0}>
|
||||||
</Button>
|
<TooltipTrigger asChild>
|
||||||
</TooltipTrigger>
|
<Button variant={currentPage === "history" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "history" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("history")}>
|
||||||
<TooltipContent side="right">
|
<HistoryIcon size={20}/>
|
||||||
<p>Audio Converter</p>
|
</Button>
|
||||||
</TooltipContent>
|
</TooltipTrigger>
|
||||||
</Tooltip>
|
<TooltipContent side="right">
|
||||||
|
<p>History</p>
|
||||||
<Tooltip delayDuration={0}>
|
</TooltipContent>
|
||||||
<TooltipTrigger asChild>
|
</Tooltip>
|
||||||
<Button variant={currentPage === "file-manager" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "file-manager" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("file-manager")}>
|
|
||||||
<FilePenIcon size={20}/>
|
<Tooltip delayDuration={0}>
|
||||||
</Button>
|
<TooltipTrigger asChild>
|
||||||
</TooltipTrigger>
|
<Button variant={currentPage === "settings" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "settings" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("settings")}>
|
||||||
<TooltipContent side="right">
|
<SettingsIcon size={20}/>
|
||||||
<p>File Manager</p>
|
</Button>
|
||||||
</TooltipContent>
|
</TooltipTrigger>
|
||||||
</Tooltip>
|
<TooltipContent side="right">
|
||||||
|
<p>Settings</p>
|
||||||
<Tooltip delayDuration={0}>
|
</TooltipContent>
|
||||||
<TooltipTrigger asChild>
|
</Tooltip>
|
||||||
<Button variant={currentPage === "debug" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "debug" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("debug")}>
|
|
||||||
<TerminalIcon size={20} loop={true}/>
|
<Tooltip delayDuration={0}>
|
||||||
</Button>
|
<TooltipTrigger asChild>
|
||||||
</TooltipTrigger>
|
<Button variant={currentPage === "debug" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "debug" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("debug")}>
|
||||||
<TooltipContent side="right">
|
<TerminalIcon size={20} loop={true}/>
|
||||||
<p>Debug Logs</p>
|
</Button>
|
||||||
</TooltipContent>
|
</TooltipTrigger>
|
||||||
</Tooltip>
|
<TooltipContent side="right">
|
||||||
|
<p>Debug Logs</p>
|
||||||
<Tooltip delayDuration={0}>
|
</TooltipContent>
|
||||||
<TooltipTrigger asChild>
|
</Tooltip>
|
||||||
<Button variant={currentPage === "settings" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "settings" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("settings")}>
|
|
||||||
<SettingsIcon size={20}/>
|
<DropdownMenu>
|
||||||
</Button>
|
<Tooltip delayDuration={0}>
|
||||||
</TooltipTrigger>
|
<DropdownMenuTrigger asChild>
|
||||||
<TooltipContent side="right">
|
<TooltipTrigger asChild>
|
||||||
<p>Settings</p>
|
<Button variant={["audio-analysis", "audio-converter", "audio-resampler", "file-manager"].includes(currentPage) ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${["audio-analysis", "audio-converter", "audio-resampler", "file-manager"].includes(currentPage) ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`}>
|
||||||
</TooltipContent>
|
<BlocksIcon size={20} loop={true}/>
|
||||||
</Tooltip>
|
</Button>
|
||||||
</div>
|
</TooltipTrigger>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<TooltipContent side="right">
|
||||||
<div className="mt-auto flex flex-col gap-2">
|
<p>Tools</p>
|
||||||
<Tooltip delayDuration={0}>
|
</TooltipContent>
|
||||||
<TooltipTrigger asChild>
|
</Tooltip>
|
||||||
<Button variant={currentPage === "about" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "about" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("about")}>
|
<DropdownMenuContent side="right" sideOffset={14} className="min-w-[200px] ml-2">
|
||||||
<BadgeAlertIcon size={20}/>
|
<DropdownMenuItem onClick={() => onPageChange("audio-analysis")} className="gap-3 cursor-pointer py-2 px-3" {...getAnimatedItemHandlers(analyzerIconRef)}>
|
||||||
</Button>
|
<ActivityIcon ref={analyzerIconRef} size={16}/>
|
||||||
</TooltipTrigger>
|
<span>Audio Quality Analyzer</span>
|
||||||
<TooltipContent side="right">
|
</DropdownMenuItem>
|
||||||
<p>About</p>
|
<DropdownMenuItem onClick={() => onPageChange("audio-resampler")} className="gap-3 cursor-pointer py-2 px-3" {...getAnimatedItemHandlers(resamplerIconRef)}>
|
||||||
</TooltipContent>
|
<AudioLinesIcon ref={resamplerIconRef} size={16}/>
|
||||||
</Tooltip>
|
<span>Audio Resampler</span>
|
||||||
<div className="relative group">
|
</DropdownMenuItem>
|
||||||
<Button variant="ghost" size="icon" className="h-10 w-10 hover:bg-primary/10 hover:text-primary">
|
<DropdownMenuItem onClick={() => onPageChange("audio-converter")} className="gap-3 cursor-pointer py-2 px-3" {...getAnimatedItemHandlers(converterIconRef)}>
|
||||||
<CoffeeIcon size={20} loop={true}/>
|
<FileMusicIcon ref={converterIconRef} size={16}/>
|
||||||
</Button>
|
<span>Audio Converter</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => onPageChange("file-manager")} className="gap-3 cursor-pointer py-2 px-3" {...getAnimatedItemHandlers(fileManagerIconRef)}>
|
||||||
<div className="absolute left-10 bottom-0 w-4 h-full bg-transparent"/>
|
<FilePenIcon ref={fileManagerIconRef} size={16}/>
|
||||||
|
<span>File Manager</span>
|
||||||
<div className="absolute left-10 bottom-0 mb-0 ml-3 hidden group-hover:flex flex-col gap-1 p-1 bg-popover border border-border rounded-md shadow-md z-50 w-max animate-in fade-in zoom-in-95 duration-200 origin-bottom-left">
|
</DropdownMenuItem>
|
||||||
<button onClick={() => openExternal("https://ko-fi.com/afkarxyz")} className="flex items-center gap-2 px-3 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground rounded-sm transition-colors text-left w-full">
|
</DropdownMenuContent>
|
||||||
<img src={KofiLogo} className="h-4 w-4" alt="Ko-fi"/>
|
</DropdownMenu>
|
||||||
Support me on Ko-fi
|
</div>
|
||||||
</button>
|
|
||||||
<button onClick={() => openExternal("https://buymeacoffee.com/afkarxyz")} className="flex items-center gap-2 px-3 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground rounded-sm transition-colors text-left w-full">
|
<div className="mt-auto flex flex-col gap-2">
|
||||||
<img src={BmcLogo} className="h-4 w-4 dark:hidden" alt="BMC"/>
|
<Dialog open={isIssuesDialogOpen} onOpenChange={handleIssuesDialogChange}>
|
||||||
<img src={BmcLogoWhite} className="h-4 w-4 hidden dark:block" alt="BMC"/>
|
<Tooltip delayDuration={0}>
|
||||||
Buy Me a Coffee
|
<TooltipTrigger asChild>
|
||||||
</button>
|
<Button variant="ghost" size="icon" className="h-10 w-10 hover:bg-primary/10 hover:text-primary" onClick={() => setIsIssuesDialogOpen(true)}>
|
||||||
</div>
|
<GithubIcon size={20}/>
|
||||||
</div>
|
</Button>
|
||||||
</div>
|
</TooltipTrigger>
|
||||||
</div>);
|
<TooltipContent side="right">
|
||||||
|
<p>Report Bugs or Request Features</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<DialogContent className="max-w-xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Before Opening GitHub Issues</DialogTitle>
|
||||||
|
<DialogDescription />
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 text-sm">
|
||||||
|
<div className="rounded-lg border border-amber-500/40 bg-amber-500/10 p-4">
|
||||||
|
<p className="font-semibold text-amber-900 dark:text-amber-200">Important</p>
|
||||||
|
<p className="mt-1 text-amber-950/90 dark:text-amber-100/90">
|
||||||
|
Search existing issues first and use the issue template when opening a new report or request.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex cursor-pointer items-center gap-3 rounded-lg border p-4">
|
||||||
|
<Checkbox className="shrink-0" checked={hasIssueAgreement} onCheckedChange={(checked) => setHasIssueAgreement(checked === true)}/>
|
||||||
|
<span className="leading-5 text-foreground/90">
|
||||||
|
I understand that I should use the issue template and avoid duplicate issues.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="sm:justify-between gap-2">
|
||||||
|
<Button variant="outline" onClick={() => handleIssuesDialogChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button disabled={!hasIssueAgreement} onClick={handleOpenIssues}>
|
||||||
|
Open Issues
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Tooltip delayDuration={0}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button variant={currentPage === "about" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "about" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("about")}>
|
||||||
|
<BadgeAlertIcon size={20}/>
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right">
|
||||||
|
<p>About</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip delayDuration={0}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="h-10 w-10 hover:bg-primary/10 hover:text-primary" onClick={() => openExternal("https://ko-fi.com/afkarxyz")}>
|
||||||
|
<CoffeeIcon size={20} loop={true}/>
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right">
|
||||||
|
<p>Support me on Ko-fi</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,463 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef, useState, forwardRef, useImperativeHandle } from "react";
|
||||||
import type { SpectrumData } from "@/types/api";
|
import type { SpectrumData } from "@/types/api";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
import { loadAudioAnalysisPreferences, saveAudioAnalysisPreferences, type AnalyzerColorScheme, type AnalyzerFreqScale, type AnalyzerWindowFunction, } from "@/lib/audio-analysis-preferences";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select";
|
||||||
|
export interface SpectrumVisualizationHandle {
|
||||||
|
getCanvasDataURL: () => string | null;
|
||||||
|
}
|
||||||
interface SpectrumVisualizationProps {
|
interface SpectrumVisualizationProps {
|
||||||
sampleRate: number;
|
sampleRate: number;
|
||||||
bitsPerSample: number;
|
|
||||||
duration: number;
|
duration: number;
|
||||||
spectrumData?: SpectrumData;
|
spectrumData?: SpectrumData;
|
||||||
|
fileName?: string;
|
||||||
|
onReAnalyze?: (fftSize: number, windowFunction: string) => void;
|
||||||
|
isAnalyzingSpectrum?: boolean;
|
||||||
|
spectrumProgress?: {
|
||||||
|
percent: number;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
export function SpectrumVisualization({ sampleRate, bitsPerSample, duration, spectrumData, }: SpectrumVisualizationProps) {
|
type ColorScheme = AnalyzerColorScheme;
|
||||||
|
type FreqScale = AnalyzerFreqScale;
|
||||||
|
type WindowFunction = AnalyzerWindowFunction;
|
||||||
|
const MARGIN = { top: 50, right: 120, bottom: 70, left: 90 };
|
||||||
|
const CANVAS_W = 1100;
|
||||||
|
const CANVAS_H = 600;
|
||||||
|
const MAX_RENDER_HEIGHT = 1080;
|
||||||
|
function clamp01(value: number): number {
|
||||||
|
return Math.max(0, Math.min(1, value));
|
||||||
|
}
|
||||||
|
function spekColorMap(t: number): [
|
||||||
|
number,
|
||||||
|
number,
|
||||||
|
number
|
||||||
|
] {
|
||||||
|
const colors: Array<[
|
||||||
|
number,
|
||||||
|
number,
|
||||||
|
number
|
||||||
|
]> = [
|
||||||
|
[0, 0, 0],
|
||||||
|
[0, 0, 25],
|
||||||
|
[0, 0, 50],
|
||||||
|
[0, 0, 80],
|
||||||
|
[20, 0, 120],
|
||||||
|
[50, 0, 150],
|
||||||
|
[80, 0, 180],
|
||||||
|
[120, 0, 120],
|
||||||
|
[150, 0, 80],
|
||||||
|
[180, 0, 40],
|
||||||
|
[210, 0, 0],
|
||||||
|
[240, 30, 0],
|
||||||
|
[255, 60, 0],
|
||||||
|
[255, 100, 0],
|
||||||
|
[255, 140, 0],
|
||||||
|
[255, 180, 0],
|
||||||
|
[255, 210, 0],
|
||||||
|
[255, 235, 0],
|
||||||
|
[255, 250, 50],
|
||||||
|
[255, 255, 100],
|
||||||
|
[255, 255, 150],
|
||||||
|
[255, 255, 200],
|
||||||
|
[255, 255, 255],
|
||||||
|
];
|
||||||
|
const scaled = t * (colors.length - 1);
|
||||||
|
const idx = Math.floor(scaled);
|
||||||
|
const fraction = scaled - idx;
|
||||||
|
if (idx >= colors.length - 1) {
|
||||||
|
return colors[colors.length - 1];
|
||||||
|
}
|
||||||
|
const c1 = colors[idx];
|
||||||
|
const c2 = colors[idx + 1];
|
||||||
|
return [
|
||||||
|
Math.round(c1[0] + (c2[0] - c1[0]) * fraction),
|
||||||
|
Math.round(c1[1] + (c2[1] - c1[1]) * fraction),
|
||||||
|
Math.round(c1[2] + (c2[2] - c1[2]) * fraction),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
function viridisColorMap(t: number): [
|
||||||
|
number,
|
||||||
|
number,
|
||||||
|
number
|
||||||
|
] {
|
||||||
|
const colors: Array<[
|
||||||
|
number,
|
||||||
|
number,
|
||||||
|
number
|
||||||
|
]> = [
|
||||||
|
[68, 1, 84],
|
||||||
|
[70, 20, 100],
|
||||||
|
[72, 40, 120],
|
||||||
|
[67, 62, 133],
|
||||||
|
[62, 74, 137],
|
||||||
|
[55, 89, 140],
|
||||||
|
[49, 104, 142],
|
||||||
|
[43, 117, 142],
|
||||||
|
[38, 130, 142],
|
||||||
|
[35, 144, 140],
|
||||||
|
[31, 158, 137],
|
||||||
|
[42, 171, 129],
|
||||||
|
[53, 183, 121],
|
||||||
|
[81, 194, 105],
|
||||||
|
[109, 205, 89],
|
||||||
|
[144, 214, 67],
|
||||||
|
[180, 222, 44],
|
||||||
|
[216, 227, 41],
|
||||||
|
[253, 231, 37],
|
||||||
|
];
|
||||||
|
const scaled = t * (colors.length - 1);
|
||||||
|
const idx = Math.floor(scaled);
|
||||||
|
const fraction = scaled - idx;
|
||||||
|
if (idx >= colors.length - 1) {
|
||||||
|
return colors[colors.length - 1];
|
||||||
|
}
|
||||||
|
const c1 = colors[idx];
|
||||||
|
const c2 = colors[idx + 1];
|
||||||
|
return [
|
||||||
|
Math.floor(c1[0] + (c2[0] - c1[0]) * fraction),
|
||||||
|
Math.floor(c1[1] + (c2[1] - c1[1]) * fraction),
|
||||||
|
Math.floor(c1[2] + (c2[2] - c1[2]) * fraction),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
function hotColorMap(t: number): [
|
||||||
|
number,
|
||||||
|
number,
|
||||||
|
number
|
||||||
|
] {
|
||||||
|
if (t < 0.33) {
|
||||||
|
return [Math.floor(t * 3 * 255), 0, 0];
|
||||||
|
}
|
||||||
|
if (t < 0.66) {
|
||||||
|
return [255, Math.floor((t - 0.33) * 3 * 255), 0];
|
||||||
|
}
|
||||||
|
return [255, 255, Math.floor((t - 0.66) * 3 * 255)];
|
||||||
|
}
|
||||||
|
function coolColorMap(t: number): [
|
||||||
|
number,
|
||||||
|
number,
|
||||||
|
number
|
||||||
|
] {
|
||||||
|
return [Math.floor(t * 255), Math.floor((1 - t) * 255), 255];
|
||||||
|
}
|
||||||
|
function getColorValues(norm: number, scheme: ColorScheme): [
|
||||||
|
number,
|
||||||
|
number,
|
||||||
|
number
|
||||||
|
] {
|
||||||
|
const value = clamp01(norm);
|
||||||
|
switch (scheme) {
|
||||||
|
case "spek":
|
||||||
|
return spekColorMap(value);
|
||||||
|
case "viridis":
|
||||||
|
return viridisColorMap(value);
|
||||||
|
case "hot":
|
||||||
|
return hotColorMap(value);
|
||||||
|
case "cool":
|
||||||
|
return coolColorMap(value);
|
||||||
|
case "grayscale":
|
||||||
|
default: {
|
||||||
|
const gray = Math.floor(value * 255);
|
||||||
|
return [gray, gray, gray];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function getColorString(norm: number, scheme: ColorScheme): string {
|
||||||
|
const [r, g, b] = getColorValues(norm, scheme);
|
||||||
|
return `rgb(${r},${g},${b})`;
|
||||||
|
}
|
||||||
|
function addAxisLabels(ctx: CanvasRenderingContext2D, plotWidth: number, plotHeight: number, sampleRate: number, duration: number, freqScale: FreqScale, fileName?: string) {
|
||||||
|
ctx.fillStyle = "#ffffff";
|
||||||
|
ctx.font = "12px Segoe UI";
|
||||||
|
ctx.textAlign = "center";
|
||||||
|
const widthFactor = plotWidth / 1000;
|
||||||
|
let timeStep: number;
|
||||||
|
if (duration <= 10) {
|
||||||
|
timeStep = widthFactor >= 1.8 ? 0.25 : (widthFactor >= 1.3 ? 0.5 : 0.5);
|
||||||
|
}
|
||||||
|
else if (duration <= 30) {
|
||||||
|
timeStep = widthFactor >= 1.8 ? 0.5 : (widthFactor >= 1.3 ? 1 : 1);
|
||||||
|
}
|
||||||
|
else if (duration <= 120) {
|
||||||
|
timeStep = widthFactor >= 1.8 ? 3 : (widthFactor >= 1.3 ? 4 : 5);
|
||||||
|
}
|
||||||
|
else if (duration <= 600) {
|
||||||
|
timeStep = widthFactor >= 1.8 ? 10 : (widthFactor >= 1.3 ? 15 : 20);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
timeStep = widthFactor >= 1.8 ? 20 : (widthFactor >= 1.3 ? 30 : 40);
|
||||||
|
}
|
||||||
|
if (duration > 0) {
|
||||||
|
for (let time = 0; time <= duration + 1e-9; time += timeStep) {
|
||||||
|
const timeProgress = time / duration;
|
||||||
|
const x = MARGIN.left + timeProgress * (plotWidth - 1);
|
||||||
|
const y = CANVAS_H - MARGIN.bottom + 20;
|
||||||
|
ctx.strokeStyle = "#ffffff";
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x, MARGIN.top + plotHeight);
|
||||||
|
ctx.lineTo(x, MARGIN.top + plotHeight + 5);
|
||||||
|
ctx.stroke();
|
||||||
|
let label: string;
|
||||||
|
if (timeStep >= 60) {
|
||||||
|
const minutes = Math.floor(time / 60);
|
||||||
|
const seconds = time % 60;
|
||||||
|
label = seconds === 0 ? `${minutes}m` : `${minutes}m${seconds}s`;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
label = `${time}s`;
|
||||||
|
}
|
||||||
|
ctx.fillText(label, x, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.textAlign = "right";
|
||||||
|
const maxFreq = sampleRate / 2;
|
||||||
|
if (freqScale === "log2") {
|
||||||
|
const heightFactor = plotHeight / 500;
|
||||||
|
const minFreq = 20;
|
||||||
|
const frequencies: number[] = [];
|
||||||
|
const octaveStep = heightFactor >= 1.5 ? 1 : (heightFactor >= 1.0 ? 1 : 2);
|
||||||
|
let octaveCount = 0;
|
||||||
|
for (let freq = minFreq; freq <= maxFreq; freq *= 2) {
|
||||||
|
if (octaveCount % octaveStep === 0) {
|
||||||
|
frequencies.push(freq);
|
||||||
|
}
|
||||||
|
octaveCount++;
|
||||||
|
}
|
||||||
|
for (const freq of frequencies) {
|
||||||
|
const freqNormalized = Math.log2(freq / minFreq) / Math.log2(maxFreq / minFreq);
|
||||||
|
const y = MARGIN.top + plotHeight * (1 - freqNormalized);
|
||||||
|
ctx.strokeStyle = "#ffffff";
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(MARGIN.left - 5, y);
|
||||||
|
ctx.lineTo(MARGIN.left, y);
|
||||||
|
ctx.stroke();
|
||||||
|
const label = freq >= 1000 ? `${(freq / 1000).toFixed(1)}k` : `${freq}`;
|
||||||
|
ctx.fillText(label, MARGIN.left - 10, y + 4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
const heightFactor = plotHeight / 500;
|
||||||
|
let freqStep: number;
|
||||||
|
if (maxFreq <= 8000) {
|
||||||
|
freqStep = heightFactor >= 1.8 ? 250 : (heightFactor >= 1.3 ? 400 : 500);
|
||||||
|
}
|
||||||
|
else if (maxFreq <= 16000) {
|
||||||
|
freqStep = heightFactor >= 1.8 ? 500 : (heightFactor >= 1.3 ? 800 : 1000);
|
||||||
|
}
|
||||||
|
else if (maxFreq <= 24000) {
|
||||||
|
freqStep = heightFactor >= 1.8 ? 1000 : (heightFactor >= 1.3 ? 1500 : 2000);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
freqStep = heightFactor >= 1.8 ? 2000 : (heightFactor >= 1.3 ? 2500 : 4000);
|
||||||
|
}
|
||||||
|
for (let freq = 0; freq <= maxFreq; freq += freqStep) {
|
||||||
|
const y = MARGIN.top + plotHeight - (freq / maxFreq) * plotHeight + 4;
|
||||||
|
const x = MARGIN.left - 15;
|
||||||
|
ctx.strokeStyle = "#ffffff";
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(MARGIN.left - 5, y - 4);
|
||||||
|
ctx.lineTo(MARGIN.left, y - 4);
|
||||||
|
ctx.stroke();
|
||||||
|
let label: string;
|
||||||
|
if (freq === 0) {
|
||||||
|
label = "0";
|
||||||
|
}
|
||||||
|
else if (freq >= 1000) {
|
||||||
|
label = freq % 1000 === 0 ? `${freq / 1000}k` : `${(freq / 1000).toFixed(1)}k`;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
label = `${freq}`;
|
||||||
|
}
|
||||||
|
ctx.fillText(label, x, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.textAlign = "center";
|
||||||
|
ctx.font = "14px Segoe UI";
|
||||||
|
ctx.fillText("Time (seconds)", CANVAS_W / 2, CANVAS_H - 15);
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(25, CANVAS_H / 2);
|
||||||
|
ctx.rotate(-Math.PI / 2);
|
||||||
|
ctx.fillText("Frequency (Hz)", 0, 0);
|
||||||
|
ctx.restore();
|
||||||
|
ctx.font = "12px Segoe UI";
|
||||||
|
if (fileName) {
|
||||||
|
ctx.textAlign = "left";
|
||||||
|
ctx.fillText(fileName, MARGIN.left + 15, 25);
|
||||||
|
}
|
||||||
|
ctx.textAlign = "right";
|
||||||
|
ctx.fillText(`Sample Rate: ${sampleRate} Hz`, CANVAS_W - 20, 25);
|
||||||
|
}
|
||||||
|
function drawColorBar(ctx: CanvasRenderingContext2D, plotHeight: number, colorScheme: ColorScheme) {
|
||||||
|
const colorBarWidth = 20;
|
||||||
|
const colorBarX = CANVAS_W - MARGIN.right + 30;
|
||||||
|
const colorBarY = MARGIN.top;
|
||||||
|
const gradient = ctx.createLinearGradient(0, colorBarY + plotHeight, 0, colorBarY);
|
||||||
|
for (let i = 0; i <= 100; i++) {
|
||||||
|
const value = i / 100;
|
||||||
|
gradient.addColorStop(value, getColorString(value, colorScheme));
|
||||||
|
}
|
||||||
|
ctx.fillStyle = gradient;
|
||||||
|
ctx.fillRect(colorBarX, colorBarY, colorBarWidth, plotHeight);
|
||||||
|
ctx.strokeStyle = "#ffffff";
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.strokeRect(colorBarX, colorBarY, colorBarWidth, plotHeight);
|
||||||
|
ctx.fillStyle = "#ffffff";
|
||||||
|
ctx.font = "10px Segoe UI";
|
||||||
|
ctx.textAlign = "left";
|
||||||
|
ctx.fillText("High", colorBarX + colorBarWidth + 5, colorBarY + 12);
|
||||||
|
ctx.fillText("Low", colorBarX + colorBarWidth + 5, colorBarY + plotHeight - 5);
|
||||||
|
}
|
||||||
|
async function renderSpectrogram(ctx: CanvasRenderingContext2D, spectrum: SpectrumData, sampleRate: number, duration: number, freqScale: FreqScale, colorScheme: ColorScheme, fileName: string | undefined, shouldCancel: () => boolean) {
|
||||||
|
const plotWidth = CANVAS_W - MARGIN.left - MARGIN.right;
|
||||||
|
const plotHeight = CANVAS_H - MARGIN.top - MARGIN.bottom;
|
||||||
|
ctx.fillStyle = "#000000";
|
||||||
|
ctx.fillRect(0, 0, CANVAS_W, CANVAS_H);
|
||||||
|
const spectrogramData = spectrum.time_slices;
|
||||||
|
const numTimeFrames = spectrogramData.length;
|
||||||
|
const numFreqBins = spectrogramData[0]?.magnitudes.length ?? 0;
|
||||||
|
if (numTimeFrames === 0 || numFreqBins === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let minMag = Number.POSITIVE_INFINITY;
|
||||||
|
let maxMag = Number.NEGATIVE_INFINITY;
|
||||||
|
const sampleStep = numTimeFrames > 10000 ? Math.floor(numTimeFrames / 5000) : 1;
|
||||||
|
for (let i = 0; i < numTimeFrames; i += sampleStep) {
|
||||||
|
const frame = spectrogramData[i].magnitudes;
|
||||||
|
for (const mag of frame) {
|
||||||
|
if (Number.isFinite(mag)) {
|
||||||
|
if (mag < minMag)
|
||||||
|
minMag = mag;
|
||||||
|
if (mag > maxMag)
|
||||||
|
maxMag = mag;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(minMag) || !Number.isFinite(maxMag)) {
|
||||||
|
minMag = -120;
|
||||||
|
maxMag = 0;
|
||||||
|
}
|
||||||
|
const magRange = maxMag - minMag;
|
||||||
|
const safeMagRange = magRange > 0 ? magRange : 1;
|
||||||
|
const highResImageData = ctx.createImageData(plotWidth, MAX_RENDER_HEIGHT);
|
||||||
|
const highResData = highResImageData.data;
|
||||||
|
const CHUNK_SIZE = 50;
|
||||||
|
for (let xStart = 0; xStart < plotWidth; xStart += CHUNK_SIZE) {
|
||||||
|
if (shouldCancel()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const xEnd = Math.min(xStart + CHUNK_SIZE, plotWidth);
|
||||||
|
for (let x = xStart; x < xEnd; x++) {
|
||||||
|
const timeProgress = x / (plotWidth - 1);
|
||||||
|
const exactTimePos = timeProgress * (numTimeFrames - 1);
|
||||||
|
const timeIdx = Math.floor(exactTimePos);
|
||||||
|
const timeIdx2 = Math.min(timeIdx + 1, numTimeFrames - 1);
|
||||||
|
const timeFrac = exactTimePos - timeIdx;
|
||||||
|
const frame1 = spectrogramData[timeIdx]?.magnitudes ?? spectrogramData[0].magnitudes;
|
||||||
|
const frame2 = spectrogramData[timeIdx2]?.magnitudes ?? frame1;
|
||||||
|
for (let y = 0; y < MAX_RENDER_HEIGHT; y++) {
|
||||||
|
let freqProgress = (MAX_RENDER_HEIGHT - 1 - y) / (MAX_RENDER_HEIGHT - 1);
|
||||||
|
if (freqScale === "log2") {
|
||||||
|
const minFreq = 20;
|
||||||
|
const maxFreq = sampleRate / 2;
|
||||||
|
const octaves = Math.log2(maxFreq / minFreq);
|
||||||
|
const octave = freqProgress * octaves;
|
||||||
|
const freq = minFreq * Math.pow(2, octave);
|
||||||
|
freqProgress = freq / maxFreq;
|
||||||
|
}
|
||||||
|
const exactFreqPos = freqProgress * (numFreqBins - 1);
|
||||||
|
const freqIdx = Math.floor(exactFreqPos);
|
||||||
|
const freqIdx2 = Math.min(freqIdx + 1, numFreqBins - 1);
|
||||||
|
const freqFrac = exactFreqPos - freqIdx;
|
||||||
|
let magnitude: number;
|
||||||
|
if (timeFrac === 0 && freqFrac === 0) {
|
||||||
|
magnitude = frame1[freqIdx] ?? 0;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
const mag11 = frame1[freqIdx] ?? 0;
|
||||||
|
const mag12 = frame1[freqIdx2] ?? 0;
|
||||||
|
const mag21 = frame2[freqIdx] ?? 0;
|
||||||
|
const mag22 = frame2[freqIdx2] ?? 0;
|
||||||
|
const magT1 = mag11 * (1 - freqFrac) + mag12 * freqFrac;
|
||||||
|
const magT2 = mag21 * (1 - freqFrac) + mag22 * freqFrac;
|
||||||
|
magnitude = magT1 * (1 - timeFrac) + magT2 * timeFrac;
|
||||||
|
}
|
||||||
|
const normalizedMag = clamp01((magnitude - minMag) / safeMagRange);
|
||||||
|
const [r, g, b] = getColorValues(normalizedMag, colorScheme);
|
||||||
|
const pixelIdx = (y * plotWidth + x) * 4;
|
||||||
|
highResData[pixelIdx] = r;
|
||||||
|
highResData[pixelIdx + 1] = g;
|
||||||
|
highResData[pixelIdx + 2] = b;
|
||||||
|
highResData[pixelIdx + 3] = 255;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (xStart + CHUNK_SIZE < plotWidth) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (shouldCancel()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const finalImageData = ctx.createImageData(plotWidth, plotHeight);
|
||||||
|
const finalData = finalImageData.data;
|
||||||
|
for (let y = 0; y < plotHeight; y++) {
|
||||||
|
for (let x = 0; x < plotWidth; x++) {
|
||||||
|
const highResY = Math.round((y / plotHeight) * MAX_RENDER_HEIGHT);
|
||||||
|
const highResIdx = (highResY * plotWidth + x) * 4;
|
||||||
|
const finalIdx = (y * plotWidth + x) * 4;
|
||||||
|
if (highResIdx < highResData.length) {
|
||||||
|
finalData[finalIdx] = highResData[highResIdx];
|
||||||
|
finalData[finalIdx + 1] = highResData[highResIdx + 1];
|
||||||
|
finalData[finalIdx + 2] = highResData[highResIdx + 2];
|
||||||
|
finalData[finalIdx + 3] = highResData[highResIdx + 3];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.putImageData(finalImageData, MARGIN.left, MARGIN.top);
|
||||||
|
addAxisLabels(ctx, plotWidth, plotHeight, sampleRate, duration, freqScale, fileName);
|
||||||
|
drawColorBar(ctx, plotHeight, colorScheme);
|
||||||
|
}
|
||||||
|
const COLOR_SCHEMES: {
|
||||||
|
value: ColorScheme;
|
||||||
|
label: string;
|
||||||
|
gradient: string;
|
||||||
|
}[] = [
|
||||||
|
{ value: "spek", label: "Spek", gradient: "linear-gradient(to right, #0f0040, #1e0080, #4000ff, #8000ff, #ff0080, #ff4000, #ff8000, #ffff00)" },
|
||||||
|
{ value: "viridis", label: "Viridis", gradient: "linear-gradient(to right, #440154, #31688e, #35b779, #fde725)" },
|
||||||
|
{ value: "hot", label: "Hot", gradient: "linear-gradient(to right, #000000, #ff0000, #ffff00, #ffffff)" },
|
||||||
|
{ value: "cool", label: "Cool", gradient: "linear-gradient(to right, #000080, #0000ff, #00ffff, #ffffff)" },
|
||||||
|
{ value: "grayscale", label: "Grayscale", gradient: "linear-gradient(to right, #000000, #ffffff)" },
|
||||||
|
];
|
||||||
|
export const SpectrumVisualization = forwardRef<SpectrumVisualizationHandle, SpectrumVisualizationProps>(({ sampleRate, duration, spectrumData, fileName, onReAnalyze, isAnalyzingSpectrum, spectrumProgress, }, ref) => {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const preferencesRef = useRef(loadAudioAnalysisPreferences());
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
getCanvasDataURL: () => {
|
||||||
|
if (!canvasRef.current)
|
||||||
|
return null;
|
||||||
|
return canvasRef.current.toDataURL("image/png");
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
const [freqScale, setFreqScale] = useState<FreqScale>(preferencesRef.current.freqScale);
|
||||||
|
const [colorScheme, setColorScheme] = useState<ColorScheme>(preferencesRef.current.colorScheme);
|
||||||
|
const [fftSize, setFftSize] = useState<string>(() => String(preferencesRef.current.fftSize));
|
||||||
|
const [windowFunction, setWindowFunction] = useState<WindowFunction>(preferencesRef.current.windowFunction);
|
||||||
|
useEffect(() => {
|
||||||
|
if (spectrumData?.freq_bins) {
|
||||||
|
setFftSize(String((spectrumData.freq_bins - 1) * 2));
|
||||||
|
}
|
||||||
|
}, [spectrumData]);
|
||||||
|
useEffect(() => {
|
||||||
|
saveAudioAnalysisPreferences({
|
||||||
|
colorScheme,
|
||||||
|
freqScale,
|
||||||
|
fftSize: Number(fftSize),
|
||||||
|
windowFunction,
|
||||||
|
});
|
||||||
|
}, [colorScheme, freqScale, fftSize, windowFunction]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
if (!canvas)
|
if (!canvas)
|
||||||
@@ -15,179 +465,107 @@ export function SpectrumVisualization({ sampleRate, bitsPerSample, duration, spe
|
|||||||
const ctx = canvas.getContext("2d");
|
const ctx = canvas.getContext("2d");
|
||||||
if (!ctx)
|
if (!ctx)
|
||||||
return;
|
return;
|
||||||
const width = canvas.width;
|
let canceled = false;
|
||||||
const height = canvas.height;
|
const shouldCancel = () => canceled;
|
||||||
const marginLeft = 70;
|
|
||||||
const marginRight = 70;
|
|
||||||
const marginTop = 30;
|
|
||||||
const marginBottom = 65;
|
|
||||||
const plotWidth = width - marginLeft - marginRight;
|
|
||||||
const plotHeight = height - marginTop - marginBottom;
|
|
||||||
ctx.fillStyle = "#000000";
|
|
||||||
ctx.fillRect(0, 0, width, height);
|
|
||||||
const nyquistFreq = sampleRate / 2;
|
|
||||||
if (spectrumData) {
|
if (spectrumData) {
|
||||||
drawRealSpectrum(ctx, marginLeft, marginTop, plotWidth, plotHeight, spectrumData);
|
void renderSpectrogram(ctx, spectrumData, sampleRate, duration, freqScale, colorScheme, fileName, shouldCancel);
|
||||||
}
|
|
||||||
drawAxesAndLabels(ctx, marginLeft, marginTop, plotWidth, plotHeight, nyquistFreq, duration, sampleRate);
|
|
||||||
drawColorBar(ctx, marginLeft + plotWidth + 15, marginTop, 20, plotHeight);
|
|
||||||
}, [sampleRate, bitsPerSample, duration, spectrumData]);
|
|
||||||
const drawRealSpectrum = (ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, spectrum: SpectrumData) => {
|
|
||||||
const timeSlices = spectrum.time_slices;
|
|
||||||
if (timeSlices.length === 0)
|
|
||||||
return;
|
|
||||||
const freqBins = timeSlices[0].magnitudes.length;
|
|
||||||
const nyquistFreq = spectrum.max_freq;
|
|
||||||
let minDB = 0;
|
|
||||||
let maxDB = -200;
|
|
||||||
timeSlices.forEach((slice) => {
|
|
||||||
slice.magnitudes.forEach((db) => {
|
|
||||||
if (db > maxDB)
|
|
||||||
maxDB = db;
|
|
||||||
if (db < minDB && db > -200)
|
|
||||||
minDB = db;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
minDB = Math.max(minDB, maxDB - 90);
|
|
||||||
const dbRange = maxDB - minDB;
|
|
||||||
const sliceWidth = Math.ceil(width / timeSlices.length);
|
|
||||||
for (let t = 0; t < timeSlices.length; t++) {
|
|
||||||
const slice = timeSlices[t];
|
|
||||||
const xPos = x + (t / timeSlices.length) * width;
|
|
||||||
for (let f = 0; f < freqBins && f < slice.magnitudes.length; f++) {
|
|
||||||
const db = slice.magnitudes[f];
|
|
||||||
const freq = (f / freqBins) * nyquistFreq;
|
|
||||||
const freqRatio = freq / nyquistFreq;
|
|
||||||
const yPos = y + height - (freqRatio * height);
|
|
||||||
const nextFreq = ((f + 1) / freqBins) * nyquistFreq;
|
|
||||||
const nextFreqRatio = nextFreq / nyquistFreq;
|
|
||||||
const nextYPos = y + height - (nextFreqRatio * height);
|
|
||||||
const binHeight = Math.max(1, Math.abs(yPos - nextYPos) + 1);
|
|
||||||
const intensity = Math.max(0, Math.min(1, (db - minDB) / dbRange));
|
|
||||||
const color = getSpekColor(intensity);
|
|
||||||
ctx.fillStyle = color;
|
|
||||||
ctx.fillRect(xPos, nextYPos, sliceWidth, binHeight);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const getSpekColor = (intensity: number): string => {
|
|
||||||
if (intensity < 0.08) {
|
|
||||||
const t = intensity / 0.08;
|
|
||||||
return `rgb(0, 0, ${Math.floor(t * 80)})`;
|
|
||||||
}
|
|
||||||
else if (intensity < 0.18) {
|
|
||||||
const t = (intensity - 0.08) / 0.10;
|
|
||||||
return `rgb(${Math.floor(t * 50)}, ${Math.floor(t * 30)}, ${Math.floor(80 + t * 175)})`;
|
|
||||||
}
|
|
||||||
else if (intensity < 0.28) {
|
|
||||||
const t = (intensity - 0.18) / 0.10;
|
|
||||||
return `rgb(${Math.floor(50 + t * 150)}, ${Math.floor(30 - t * 30)}, ${Math.floor(255 - t * 55)})`;
|
|
||||||
}
|
|
||||||
else if (intensity < 0.40) {
|
|
||||||
const t = (intensity - 0.28) / 0.12;
|
|
||||||
return `rgb(${Math.floor(200 + t * 55)}, 0, ${Math.floor(200 - t * 200)})`;
|
|
||||||
}
|
|
||||||
else if (intensity < 0.52) {
|
|
||||||
const t = (intensity - 0.40) / 0.12;
|
|
||||||
return `rgb(255, ${Math.floor(t * 100)}, 0)`;
|
|
||||||
}
|
|
||||||
else if (intensity < 0.65) {
|
|
||||||
const t = (intensity - 0.52) / 0.13;
|
|
||||||
return `rgb(255, ${Math.floor(100 + t * 80)}, 0)`;
|
|
||||||
}
|
|
||||||
else if (intensity < 0.78) {
|
|
||||||
const t = (intensity - 0.65) / 0.13;
|
|
||||||
return `rgb(255, ${Math.floor(180 + t * 55)}, ${Math.floor(t * 30)})`;
|
|
||||||
}
|
|
||||||
else if (intensity < 0.90) {
|
|
||||||
const t = (intensity - 0.78) / 0.12;
|
|
||||||
return `rgb(255, ${Math.floor(235 + t * 20)}, ${Math.floor(30 + t * 100)})`;
|
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
const t = (intensity - 0.90) / 0.10;
|
ctx.fillStyle = "#000000";
|
||||||
return `rgb(255, 255, ${Math.floor(130 + t * 125)})`;
|
ctx.fillRect(0, 0, CANVAS_W, CANVAS_H);
|
||||||
|
ctx.fillStyle = "#444444";
|
||||||
|
ctx.font = "16px Arial";
|
||||||
|
ctx.textAlign = "center";
|
||||||
|
ctx.fillText("No spectrum data", CANVAS_W / 2, CANVAS_H / 2);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
canceled = true;
|
||||||
|
};
|
||||||
|
}, [spectrumData, sampleRate, duration, freqScale, colorScheme, fileName]);
|
||||||
|
const handleReAnalyze = (newFftSize: string, newWindowFunc: string) => {
|
||||||
|
setFftSize(newFftSize);
|
||||||
|
setWindowFunction(newWindowFunc as WindowFunction);
|
||||||
|
if (onReAnalyze) {
|
||||||
|
onReAnalyze(parseInt(newFftSize, 10), newWindowFunc);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const drawAxesAndLabels = (ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, nyquistFreq: number, duration: number, sampleRate: number) => {
|
const spectrumPercent = Math.round(Math.max(0, Math.min(100, spectrumProgress?.percent ?? 0)));
|
||||||
ctx.fillStyle = "#CCCCCC";
|
return (<div className="space-y-4">
|
||||||
ctx.font = "12px Arial";
|
<div className="flex flex-wrap items-center gap-3 sm:gap-4 p-1">
|
||||||
ctx.textAlign = "right";
|
<div className="flex items-center gap-2">
|
||||||
ctx.textBaseline = "middle";
|
<Label className="whitespace-nowrap text-sm font-medium">Color Scheme:</Label>
|
||||||
const freqLabels = generateFreqLabels(nyquistFreq);
|
<Select value={colorScheme} onValueChange={(v) => setColorScheme(v as ColorScheme)} disabled={isAnalyzingSpectrum}>
|
||||||
freqLabels.forEach(freq => {
|
<SelectTrigger className="h-8 w-[130px] text-sm">
|
||||||
if (freq <= nyquistFreq) {
|
<SelectValue />
|
||||||
const freqRatio = freq / nyquistFreq;
|
</SelectTrigger>
|
||||||
const yPos = y + height - (freqRatio * height);
|
<SelectContent>
|
||||||
const label = freq >= 1000 ? `${freq / 1000}k` : `${freq}`;
|
{COLOR_SCHEMES.map((scheme) => (<SelectItem key={scheme.value} value={scheme.value}>
|
||||||
ctx.fillText(label, x - 8, yPos);
|
<div className="flex items-center gap-2">
|
||||||
}
|
<div className="h-4 w-4 rounded-sm border opacity-90" style={{ backgroundImage: scheme.gradient }}/>
|
||||||
});
|
<span>{scheme.label}</span>
|
||||||
ctx.fillText("0", x - 8, y + height);
|
</div>
|
||||||
ctx.textAlign = "center";
|
</SelectItem>))}
|
||||||
ctx.textBaseline = "top";
|
</SelectContent>
|
||||||
const timeStep = getTimeStep(duration);
|
</Select>
|
||||||
for (let t = 0; t <= duration; t += timeStep) {
|
</div>
|
||||||
const xPos = x + (t / duration) * width;
|
|
||||||
ctx.fillText(`${Math.round(t)}s`, xPos, y + height + 8);
|
<div className="h-6 w-px bg-border hidden sm:block mx-1"></div>
|
||||||
}
|
|
||||||
ctx.fillStyle = "#FFFFFF";
|
<div className="flex items-center gap-2">
|
||||||
ctx.font = "13px Arial";
|
<Label className="whitespace-nowrap text-sm font-medium">Freq Scale:</Label>
|
||||||
ctx.save();
|
<Select value={freqScale} onValueChange={(v) => setFreqScale(v as FreqScale)} disabled={isAnalyzingSpectrum}>
|
||||||
ctx.translate(12, y + height / 2);
|
<SelectTrigger className="h-8 w-[95px] text-sm">
|
||||||
ctx.rotate(-Math.PI / 2);
|
<SelectValue />
|
||||||
ctx.textAlign = "center";
|
</SelectTrigger>
|
||||||
ctx.fillText("Frequency (Hz)", 0, 0);
|
<SelectContent>
|
||||||
ctx.restore();
|
<SelectItem value="linear">Linear</SelectItem>
|
||||||
ctx.textAlign = "center";
|
<SelectItem value="log2">Log2</SelectItem>
|
||||||
ctx.fillText("Time (seconds)", x + width / 2, y + height + 35);
|
</SelectContent>
|
||||||
ctx.textAlign = "right";
|
</Select>
|
||||||
ctx.fillStyle = "#CCCCCC";
|
</div>
|
||||||
ctx.font = "12px Arial";
|
|
||||||
ctx.fillText(`Sample Rate: ${sampleRate} Hz`, x + width - 5, y - 3);
|
<div className="flex items-center gap-2">
|
||||||
};
|
<Label className="whitespace-nowrap text-sm font-medium">FFT Size:</Label>
|
||||||
const generateFreqLabels = (nyquistFreq: number): number[] => {
|
<Select value={fftSize} onValueChange={(v) => handleReAnalyze(v, windowFunction)} disabled={isAnalyzingSpectrum}>
|
||||||
if (nyquistFreq <= 24000) {
|
<SelectTrigger className="h-8 w-[90px] text-sm">
|
||||||
return [2000, 4000, 6000, 8000, 10000, 12000, 14000, 16000, 18000, 20000, 22000];
|
<SelectValue />
|
||||||
}
|
</SelectTrigger>
|
||||||
else if (nyquistFreq <= 48000) {
|
<SelectContent>
|
||||||
return [5000, 10000, 15000, 20000, 25000, 30000, 35000, 40000, 45000];
|
<SelectItem value="512">512</SelectItem>
|
||||||
}
|
<SelectItem value="1024">1024</SelectItem>
|
||||||
else if (nyquistFreq <= 96000) {
|
<SelectItem value="2048">2048</SelectItem>
|
||||||
return [10000, 20000, 30000, 40000, 50000, 60000, 70000, 80000, 90000];
|
<SelectItem value="4096">4096</SelectItem>
|
||||||
}
|
</SelectContent>
|
||||||
else {
|
</Select>
|
||||||
return [20000, 40000, 60000, 80000, 100000, 120000, 140000, 160000, 180000];
|
</div>
|
||||||
}
|
|
||||||
};
|
<div className="flex items-center gap-2">
|
||||||
const getTimeStep = (duration: number): number => {
|
<Label className="whitespace-nowrap text-sm font-medium">Window:</Label>
|
||||||
if (duration <= 60)
|
<Select value={windowFunction} onValueChange={(v) => handleReAnalyze(fftSize, v)} disabled={isAnalyzingSpectrum}>
|
||||||
return 15;
|
<SelectTrigger className="h-8 w-[120px] text-sm capitalize">
|
||||||
if (duration <= 120)
|
<SelectValue />
|
||||||
return 30;
|
</SelectTrigger>
|
||||||
if (duration <= 300)
|
<SelectContent>
|
||||||
return 30;
|
<SelectItem value="hann">Hann</SelectItem>
|
||||||
if (duration <= 600)
|
<SelectItem value="hamming">Hamming</SelectItem>
|
||||||
return 60;
|
<SelectItem value="blackman">Blackman</SelectItem>
|
||||||
return 60;
|
<SelectItem value="rectangular">Rectangular</SelectItem>
|
||||||
};
|
</SelectContent>
|
||||||
const drawColorBar = (ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number) => {
|
</Select>
|
||||||
for (let i = 0; i < height; i++) {
|
</div>
|
||||||
const intensity = 1 - (i / height);
|
</div>
|
||||||
const color = getSpekColor(intensity);
|
|
||||||
ctx.fillStyle = color;
|
<div className="relative border border-white/10 rounded-lg overflow-hidden bg-black shadow-xl">
|
||||||
ctx.fillRect(x, y + i, width, 1);
|
{isAnalyzingSpectrum && (<div className="absolute inset-0 z-10 grid place-items-center bg-black/60 backdrop-blur-sm">
|
||||||
}
|
<div className="w-full max-w-xs space-y-2 px-4">
|
||||||
ctx.strokeStyle = "#666666";
|
<div className="flex items-center justify-between text-sm text-foreground/90">
|
||||||
ctx.lineWidth = 1;
|
<span>Processing...</span>
|
||||||
ctx.strokeRect(x, y, width, height);
|
<span className="tabular-nums">{spectrumPercent}%</span>
|
||||||
ctx.fillStyle = "#FFFFFF";
|
</div>
|
||||||
ctx.font = "11px Arial";
|
<Progress value={spectrumPercent} className="h-2 w-full"/>
|
||||||
ctx.textAlign = "left";
|
</div>
|
||||||
ctx.textBaseline = "middle";
|
</div>)}
|
||||||
ctx.fillText("High", x + width + 5, y + 10);
|
<canvas ref={canvasRef} width={CANVAS_W} height={CANVAS_H} className="w-full h-auto" style={{ imageRendering: "auto" }}/>
|
||||||
ctx.fillText("Low", x + width + 5, y + height - 10);
|
</div>
|
||||||
};
|
</div>);
|
||||||
return (<div className="border border-white/10 rounded-lg overflow-hidden bg-black shadow-xl">
|
});
|
||||||
<canvas ref={canvasRef} width={1200} height={600} className="w-full h-auto" style={{ imageRendering: "auto" }}/>
|
|
||||||
</div>);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { X, Minus, Maximize, Settings, Info } from "lucide-react";
|
import { X, Minus, Maximize, SlidersHorizontal, Info } from "lucide-react";
|
||||||
import { WindowMinimise, WindowToggleMaximise, Quit } from "../../wailsjs/runtime/runtime";
|
import { WindowMinimise, WindowToggleMaximise, Quit } from "../../wailsjs/runtime/runtime";
|
||||||
import { Menubar, MenubarContent, MenubarMenu, MenubarItem, MenubarTrigger, MenubarLabel, MenubarSeparator } from "@/components/ui/menubar";
|
import { Menubar, MenubarContent, MenubarMenu, MenubarItem, MenubarTrigger, MenubarLabel, MenubarSeparator } from "@/components/ui/menubar";
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
@@ -11,6 +11,14 @@ export function TitleBar() {
|
|||||||
if (settings) {
|
if (settings) {
|
||||||
setUseSpotFetchAPI(settings.useSpotFetchAPI || false);
|
setUseSpotFetchAPI(settings.useSpotFetchAPI || false);
|
||||||
}
|
}
|
||||||
|
const handleSettingsUpdate = (event: any) => {
|
||||||
|
const updatedSettings = event.detail;
|
||||||
|
if (updatedSettings && typeof updatedSettings.useSpotFetchAPI !== 'undefined') {
|
||||||
|
setUseSpotFetchAPI(updatedSettings.useSpotFetchAPI);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('settingsUpdated', handleSettingsUpdate);
|
||||||
|
return () => window.removeEventListener('settingsUpdated', handleSettingsUpdate);
|
||||||
}, []);
|
}, []);
|
||||||
const handleSpotFetchAPIToggle = () => {
|
const handleSpotFetchAPIToggle = () => {
|
||||||
const newValue = !useSpotFetchAPI;
|
const newValue = !useSpotFetchAPI;
|
||||||
@@ -35,7 +43,7 @@ export function TitleBar() {
|
|||||||
<Menubar className="border-none bg-transparent shadow-none px-0 mr-1" style={{ "--wails-draggable": "no-drag" } as React.CSSProperties}>
|
<Menubar className="border-none bg-transparent shadow-none px-0 mr-1" style={{ "--wails-draggable": "no-drag" } as React.CSSProperties}>
|
||||||
<MenubarMenu>
|
<MenubarMenu>
|
||||||
<MenubarTrigger className="cursor-pointer w-8 h-7 p-0 flex items-center justify-center hover:bg-muted transition-colors rounded data-[state=open]:bg-muted">
|
<MenubarTrigger className="cursor-pointer w-8 h-7 p-0 flex items-center justify-center hover:bg-muted transition-colors rounded data-[state=open]:bg-muted">
|
||||||
<Settings className="w-3.5 h-3.5"/>
|
<SlidersHorizontal className="w-3.5 h-3.5"/>
|
||||||
</MenubarTrigger>
|
</MenubarTrigger>
|
||||||
<MenubarContent align="end" className="min-w-[200px]">
|
<MenubarContent align="end" className="min-w-[200px]">
|
||||||
<div className="flex items-center gap-1.5 px-2 py-1.5">
|
<div className="flex items-center gap-1.5 px-2 py-1.5">
|
||||||
|
|||||||
@@ -26,9 +26,9 @@ interface TrackInfoProps {
|
|||||||
downloadedCover?: boolean;
|
downloadedCover?: boolean;
|
||||||
failedCover?: boolean;
|
failedCover?: boolean;
|
||||||
skippedCover?: boolean;
|
skippedCover?: boolean;
|
||||||
onDownload: (isrc: string, name: string, artists: string, albumName?: string, spotifyId?: string, playlistName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => void;
|
onDownload: (id: string, name: string, artists: string, albumName?: string, spotifyId?: string, playlistName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => void;
|
||||||
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
|
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
|
||||||
onCheckAvailability?: (spotifyId: string, isrc?: string) => void;
|
onCheckAvailability?: (spotifyId: string) => void;
|
||||||
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName?: string, playlistName?: string, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
|
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName?: string, playlistName?: string, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
|
||||||
onOpenFolder: () => void;
|
onOpenFolder: () => void;
|
||||||
onBack?: () => void;
|
onBack?: () => void;
|
||||||
@@ -95,9 +95,9 @@ export function TrackInfo({ track, isDownloading, downloadingTrack, isDownloaded
|
|||||||
</div>)}
|
</div>)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{track.isrc && (<div className="flex gap-2 flex-wrap">
|
{track.spotify_id && (<div className="flex gap-2 flex-wrap">
|
||||||
<Button onClick={() => onDownload(track.isrc, track.name, track.artists, track.album_name, track.spotify_id, undefined, track.duration_ms, track.track_number, track.album_artist, track.release_date, track.images, track.track_number, track.disc_number, track.total_tracks, track.total_discs, track.copyright, track.publisher)} disabled={isDownloading || downloadingTrack === track.isrc}>
|
<Button onClick={() => onDownload(track.spotify_id || "", track.name, track.artists, track.album_name, track.spotify_id, undefined, track.duration_ms, track.track_number, track.album_artist, track.release_date, track.images, track.track_number, track.disc_number, track.total_tracks, track.total_discs, track.copyright, track.publisher)} disabled={isDownloading || downloadingTrack === track.spotify_id}>
|
||||||
{downloadingTrack === track.isrc ? (<Spinner />) : (<>
|
{downloadingTrack === track.spotify_id ? (<Spinner />) : (<>
|
||||||
<Download className="h-4 w-4"/>
|
<Download className="h-4 w-4"/>
|
||||||
Download
|
Download
|
||||||
</>)}
|
</>)}
|
||||||
@@ -119,7 +119,7 @@ export function TrackInfo({ track, isDownloading, downloadingTrack, isDownloaded
|
|||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>Download Lyric</p>
|
<p>Download Separate Lyric</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>)}
|
</Tooltip>)}
|
||||||
{track.images && onDownloadCover && (<Tooltip>
|
{track.images && onDownloadCover && (<Tooltip>
|
||||||
@@ -129,17 +129,17 @@ export function TrackInfo({ track, isDownloading, downloadingTrack, isDownloaded
|
|||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>Download Cover</p>
|
<p>Download Separate Cover</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>)}
|
</Tooltip>)}
|
||||||
{track.spotify_id && onCheckAvailability && (<Tooltip>
|
{track.spotify_id && onCheckAvailability && (<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button onClick={() => onCheckAvailability(track.spotify_id!, track.isrc)} variant="outline" size="icon" disabled={checkingAvailability}>
|
<Button onClick={() => onCheckAvailability(track.spotify_id!)} variant="outline" size="icon" disabled={checkingAvailability}>
|
||||||
{checkingAvailability ? (<Spinner />) : availability ? (<CheckCircle className="h-4 w-4 text-green-500"/>) : (<Globe className="h-4 w-4"/>)}
|
{checkingAvailability ? (<Spinner />) : availability ? (<CheckCircle className="h-4 w-4 text-green-500"/>) : (<Globe className="h-4 w-4"/>)}
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
{availability ? (<div className="flex items-center gap-2">
|
{availability ? (<div className="flex items-center gap-2">
|
||||||
<TidalIcon className={`w-4 h-4 ${availability.tidal ? "text-green-500" : "text-red-500"}`}/>
|
<TidalIcon className={`w-4 h-4 ${availability.tidal ? "text-green-500" : "text-red-500"}`}/>
|
||||||
<QobuzIcon className={`w-4 h-4 ${availability.qobuz ? "text-green-500" : "text-red-500"}`}/>
|
<QobuzIcon className={`w-4 h-4 ${availability.qobuz ? "text-green-500" : "text-red-500"}`}/>
|
||||||
<AmazonIcon className={`w-4 h-4 ${availability.amazon ? "text-green-500" : "text-red-500"}`}/>
|
<AmazonIcon className={`w-4 h-4 ${availability.amazon ? "text-green-500" : "text-red-500"}`}/>
|
||||||
|
|||||||
@@ -33,11 +33,11 @@ interface TrackListProps {
|
|||||||
failedCovers?: Set<string>;
|
failedCovers?: Set<string>;
|
||||||
skippedCovers?: Set<string>;
|
skippedCovers?: Set<string>;
|
||||||
downloadingCoverTrack?: string | null;
|
downloadingCoverTrack?: string | null;
|
||||||
onToggleTrack: (isrc: string) => void;
|
onToggleTrack: (id: string) => void;
|
||||||
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
|
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
|
||||||
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => void;
|
onDownloadTrack: (id: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => void;
|
||||||
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
|
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
|
||||||
onCheckAvailability?: (spotifyId: string, isrc?: string) => void;
|
onCheckAvailability?: (spotifyId: string) => void;
|
||||||
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
|
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
|
||||||
onPageChange: (page: number) => void;
|
onPageChange: (page: number) => void;
|
||||||
onAlbumClick?: (album: {
|
onAlbumClick?: (album: {
|
||||||
@@ -104,18 +104,25 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa
|
|||||||
}
|
}
|
||||||
else if (sortBy === "downloaded") {
|
else if (sortBy === "downloaded") {
|
||||||
filteredTracks = [...filteredTracks].sort((a, b) => {
|
filteredTracks = [...filteredTracks].sort((a, b) => {
|
||||||
const aDownloaded = downloadedTracks.has(a.isrc);
|
const aDownloaded = a.spotify_id ? downloadedTracks.has(a.spotify_id) : false;
|
||||||
const bDownloaded = downloadedTracks.has(b.isrc);
|
const bDownloaded = b.spotify_id ? downloadedTracks.has(b.spotify_id) : false;
|
||||||
return (bDownloaded ? 1 : 0) - (aDownloaded ? 1 : 0);
|
return (bDownloaded ? 1 : 0) - (aDownloaded ? 1 : 0);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
else if (sortBy === "not-downloaded") {
|
else if (sortBy === "not-downloaded") {
|
||||||
filteredTracks = [...filteredTracks].sort((a, b) => {
|
filteredTracks = [...filteredTracks].sort((a, b) => {
|
||||||
const aDownloaded = downloadedTracks.has(a.isrc);
|
const aDownloaded = a.spotify_id ? downloadedTracks.has(a.spotify_id) : false;
|
||||||
const bDownloaded = downloadedTracks.has(b.isrc);
|
const bDownloaded = b.spotify_id ? downloadedTracks.has(b.spotify_id) : false;
|
||||||
return (aDownloaded ? 1 : 0) - (bDownloaded ? 1 : 0);
|
return (aDownloaded ? 1 : 0) - (bDownloaded ? 1 : 0);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
else if (sortBy === "failed") {
|
||||||
|
filteredTracks = [...filteredTracks].sort((a, b) => {
|
||||||
|
const aFailed = a.spotify_id ? failedTracks.has(a.spotify_id) : false;
|
||||||
|
const bFailed = b.spotify_id ? failedTracks.has(b.spotify_id) : false;
|
||||||
|
return (bFailed ? 1 : 0) - (aFailed ? 1 : 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
const totalPages = Math.ceil(filteredTracks.length / itemsPerPage);
|
const totalPages = Math.ceil(filteredTracks.length / itemsPerPage);
|
||||||
const startIndex = (currentPage - 1) * itemsPerPage;
|
const startIndex = (currentPage - 1) * itemsPerPage;
|
||||||
const endIndex = startIndex + itemsPerPage;
|
const endIndex = startIndex + itemsPerPage;
|
||||||
@@ -149,9 +156,9 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa
|
|||||||
}
|
}
|
||||||
return pages;
|
return pages;
|
||||||
};
|
};
|
||||||
const tracksWithIsrc = filteredTracks.filter((track) => track.isrc);
|
const tracksWithId = filteredTracks.filter((track) => track.spotify_id);
|
||||||
const allSelected = tracksWithIsrc.length > 0 &&
|
const allSelected = tracksWithId.length > 0 &&
|
||||||
tracksWithIsrc.every((track) => selectedTracks.includes(track.isrc));
|
tracksWithId.every((track) => selectedTracks.includes(track.spotify_id!));
|
||||||
const formatDuration = (ms: number) => {
|
const formatDuration = (ms: number) => {
|
||||||
const minutes = Math.floor(ms / 60000);
|
const minutes = Math.floor(ms / 60000);
|
||||||
const seconds = Math.floor((ms % 60000) / 1000);
|
const seconds = Math.floor((ms % 60000) / 1000);
|
||||||
@@ -197,7 +204,7 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa
|
|||||||
<tbody>
|
<tbody>
|
||||||
{paginatedTracks.map((track, index) => (<tr key={index} className="border-b transition-colors hover:bg-muted/50">
|
{paginatedTracks.map((track, index) => (<tr key={index} className="border-b transition-colors hover:bg-muted/50">
|
||||||
{showCheckboxes && (<td className="p-4 align-middle">
|
{showCheckboxes && (<td className="p-4 align-middle">
|
||||||
{track.isrc && (<Checkbox checked={selectedTracks.includes(track.isrc)} onCheckedChange={() => onToggleTrack(track.isrc)}/>)}
|
{track.spotify_id && (<Checkbox checked={selectedTracks.includes(track.spotify_id)} onCheckedChange={() => onToggleTrack(track.spotify_id!)}/>)}
|
||||||
</td>)}
|
</td>)}
|
||||||
<td className="p-4 align-middle text-sm text-muted-foreground">
|
<td className="p-4 align-middle text-sm text-muted-foreground">
|
||||||
<div className="flex flex-col items-center gap-0.5">
|
<div className="flex flex-col items-center gap-0.5">
|
||||||
@@ -223,7 +230,7 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa
|
|||||||
</span>) : (<span className="font-medium">{track.name}</span>)}
|
</span>) : (<span className="font-medium">{track.name}</span>)}
|
||||||
{track.is_explicit && (<span className="inline-flex items-center justify-center bg-red-600 text-white text-[10px] h-4 w-4 rounded shrink-0" title="Explicit">E</span>)}
|
{track.is_explicit && (<span className="inline-flex items-center justify-center bg-red-600 text-white text-[10px] h-4 w-4 rounded shrink-0" title="Explicit">E</span>)}
|
||||||
|
|
||||||
{skippedTracks.has(track.isrc) ? (<FileCheck 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"/>) : failedTracks.has(track.isrc) ? (<XCircle className="h-4 w-4 text-red-500 shrink-0"/>) : null}
|
{track.spotify_id && skippedTracks.has(track.spotify_id) ? (<FileCheck className="h-4 w-4 text-yellow-500 shrink-0"/>) : track.spotify_id && downloadedTracks.has(track.spotify_id) ? (<CheckCircle className="h-4 w-4 text-green-500 shrink-0"/>) : track.spotify_id && failedTracks.has(track.spotify_id) ? (<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_data && track.artists_data.length > 0 ? ((() => {
|
{track.artists_data && track.artists_data.length > 0 ? ((() => {
|
||||||
@@ -270,14 +277,14 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa
|
|||||||
</td>
|
</td>
|
||||||
<td className="p-4 align-middle text-center">
|
<td className="p-4 align-middle text-center">
|
||||||
<div className="flex items-center justify-center gap-1">
|
<div className="flex items-center justify-center gap-1">
|
||||||
{track.isrc && (<Tooltip>
|
{track.spotify_id && (<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button onClick={() => onDownloadTrack(track.isrc, track.name, track.artists, track.album_name, track.spotify_id, folderName, track.duration_ms, startIndex + index + 1, track.album_artist, track.release_date, track.images, track.track_number, track.disc_number, track.total_tracks, track.total_discs, track.copyright, track.publisher)} size="icon" disabled={isDownloading || downloadingTrack === track.isrc}>
|
<Button onClick={() => onDownloadTrack(track.spotify_id!, track.name, track.artists, track.album_name, track.spotify_id, folderName, track.duration_ms, startIndex + index + 1, track.album_artist, track.release_date, track.images, track.track_number, track.disc_number, track.total_tracks, track.total_discs, track.copyright, track.publisher)} size="icon" disabled={isDownloading || downloadingTrack === track.spotify_id}>
|
||||||
{downloadingTrack === track.isrc ? (<Spinner />) : skippedTracks.has(track.isrc) ? (<FileCheck className="h-4 w-4"/>) : downloadedTracks.has(track.isrc) ? (<CheckCircle className="h-4 w-4"/>) : failedTracks.has(track.isrc) ? (<XCircle className="h-4 w-4"/>) : (<Download className="h-4 w-4"/>)}
|
{downloadingTrack === track.spotify_id ? (<Spinner />) : skippedTracks.has(track.spotify_id) ? (<FileCheck className="h-4 w-4"/>) : downloadedTracks.has(track.spotify_id) ? (<CheckCircle className="h-4 w-4"/>) : failedTracks.has(track.spotify_id) ? (<XCircle className="h-4 w-4"/>) : (<Download className="h-4 w-4"/>)}
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
{downloadingTrack === track.isrc ? (<p>Downloading...</p>) : skippedTracks.has(track.isrc) ? (<p>Already exists</p>) : downloadedTracks.has(track.isrc) ? (<p>Downloaded</p>) : failedTracks.has(track.isrc) ? (<p>Failed</p>) : (<p>Download Track</p>)}
|
{downloadingTrack === track.spotify_id ? (<p>Downloading...</p>) : skippedTracks.has(track.spotify_id) ? (<p>Already exists</p>) : downloadedTracks.has(track.spotify_id) ? (<p>Downloaded</p>) : failedTracks.has(track.spotify_id) ? (<p>Failed</p>) : (<p>Download Track</p>)}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>)}
|
</Tooltip>)}
|
||||||
{track.spotify_id && (<Tooltip>
|
{track.spotify_id && (<Tooltip>
|
||||||
@@ -297,7 +304,7 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa
|
|||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>Download Lyric</p>
|
<p>Download Separate Lyric</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>)}
|
</Tooltip>)}
|
||||||
{track.images && onDownloadCover && (<Tooltip>
|
{track.images && onDownloadCover && (<Tooltip>
|
||||||
@@ -310,12 +317,12 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa
|
|||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>Download Cover</p>
|
<p>Download Separate Cover</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>)}
|
</Tooltip>)}
|
||||||
{track.spotify_id && onCheckAvailability && (<Tooltip>
|
{track.spotify_id && onCheckAvailability && (<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button onClick={() => onCheckAvailability(track.spotify_id!, track.isrc)} size="icon" variant="outline" disabled={checkingAvailabilityTrack === track.spotify_id}>
|
<Button onClick={() => onCheckAvailability(track.spotify_id!)} size="icon" variant="outline" disabled={checkingAvailabilityTrack === track.spotify_id}>
|
||||||
{checkingAvailabilityTrack === track.spotify_id ? (<Spinner />) : availabilityMap?.has(track.spotify_id) ? (<CheckCircle className="h-4 w-4 text-green-500"/>) : (<Globe className="h-4 w-4"/>)}
|
{checkingAvailabilityTrack === track.spotify_id ? (<Spinner />) : availabilityMap?.has(track.spotify_id) ? (<CheckCircle className="h-4 w-4 text-green-500"/>) : (<Globe className="h-4 w-4"/>)}
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"use client";
|
||||||
|
import { motion, useAnimation } from "motion/react";
|
||||||
|
import type { HTMLAttributes } from "react";
|
||||||
|
import { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
export interface AudioLinesIconHandle {
|
||||||
|
startAnimation: () => void;
|
||||||
|
stopAnimation: () => void;
|
||||||
|
}
|
||||||
|
interface AudioLinesIconProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
const AudioLinesIcon = forwardRef<AudioLinesIconHandle, AudioLinesIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
|
||||||
|
const controls = useAnimation();
|
||||||
|
const isControlledRef = useRef(false);
|
||||||
|
useImperativeHandle(ref, () => {
|
||||||
|
isControlledRef.current = true;
|
||||||
|
return {
|
||||||
|
startAnimation: () => controls.start("animate"),
|
||||||
|
stopAnimation: () => controls.start("normal"),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const handleMouseEnter = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (isControlledRef.current) {
|
||||||
|
onMouseEnter?.(e);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
controls.start("animate");
|
||||||
|
}
|
||||||
|
}, [controls, onMouseEnter]);
|
||||||
|
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (isControlledRef.current) {
|
||||||
|
onMouseLeave?.(e);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
controls.start("normal");
|
||||||
|
}
|
||||||
|
}, [controls, onMouseLeave]);
|
||||||
|
return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
|
||||||
|
<svg fill="none" height={size} stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" viewBox="0 0 24 24" width={size} xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M2 10v3"/>
|
||||||
|
<motion.path animate={controls} d="M6 6v11" variants={{
|
||||||
|
normal: { d: "M6 6v11" },
|
||||||
|
animate: {
|
||||||
|
d: ["M6 6v11", "M6 10v3", "M6 6v11"],
|
||||||
|
transition: {
|
||||||
|
duration: 1.5,
|
||||||
|
repeat: Number.POSITIVE_INFINITY,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}/>
|
||||||
|
<motion.path animate={controls} d="M10 3v18" variants={{
|
||||||
|
normal: { d: "M10 3v18" },
|
||||||
|
animate: {
|
||||||
|
d: ["M10 3v18", "M10 9v5", "M10 3v18"],
|
||||||
|
transition: {
|
||||||
|
duration: 1,
|
||||||
|
repeat: Number.POSITIVE_INFINITY,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}/>
|
||||||
|
<motion.path animate={controls} d="M14 8v7" variants={{
|
||||||
|
normal: { d: "M14 8v7" },
|
||||||
|
animate: {
|
||||||
|
d: ["M14 8v7", "M14 6v11", "M14 8v7"],
|
||||||
|
transition: {
|
||||||
|
duration: 0.8,
|
||||||
|
repeat: Number.POSITIVE_INFINITY,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}/>
|
||||||
|
<motion.path animate={controls} d="M18 5v13" variants={{
|
||||||
|
normal: { d: "M18 5v13" },
|
||||||
|
animate: {
|
||||||
|
d: ["M18 5v13", "M18 7v9", "M18 5v13"],
|
||||||
|
transition: {
|
||||||
|
duration: 1.5,
|
||||||
|
repeat: Number.POSITIVE_INFINITY,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}/>
|
||||||
|
<path d="M22 10v3"/>
|
||||||
|
</svg>
|
||||||
|
</div>);
|
||||||
|
});
|
||||||
|
AudioLinesIcon.displayName = "AudioLinesIcon";
|
||||||
|
export { AudioLinesIcon };
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"use client";
|
||||||
|
import type { Variants } from "motion/react";
|
||||||
|
import { motion, useAnimation } from "motion/react";
|
||||||
|
import type { HTMLAttributes } from "react";
|
||||||
|
import { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
export interface BlocksIconHandle {
|
||||||
|
startAnimation: () => void;
|
||||||
|
stopAnimation: () => void;
|
||||||
|
}
|
||||||
|
interface BlocksIconProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
|
size?: number;
|
||||||
|
loop?: boolean;
|
||||||
|
}
|
||||||
|
const VARIANTS: Variants = {
|
||||||
|
normal: { translateX: 0, translateY: 0 },
|
||||||
|
animate: { translateX: -4, translateY: 4 },
|
||||||
|
};
|
||||||
|
const BlocksIcon = forwardRef<BlocksIconHandle, BlocksIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, loop = false, ...props }, ref) => {
|
||||||
|
const controls = useAnimation();
|
||||||
|
const isControlledRef = useRef(false);
|
||||||
|
useImperativeHandle(ref, () => {
|
||||||
|
isControlledRef.current = true;
|
||||||
|
return {
|
||||||
|
startAnimation: () => controls.start("animate"),
|
||||||
|
stopAnimation: () => controls.start("normal"),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const handleMouseEnter = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (isControlledRef.current) {
|
||||||
|
onMouseEnter?.(e);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
controls.start("animate");
|
||||||
|
}
|
||||||
|
}, [controls, onMouseEnter]);
|
||||||
|
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (isControlledRef.current) {
|
||||||
|
onMouseLeave?.(e);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
controls.start("normal");
|
||||||
|
}
|
||||||
|
}, [controls, onMouseLeave]);
|
||||||
|
return (<div className={cn("flex items-center justify-center", className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
|
||||||
|
<svg fill="none" height={size} stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" viewBox="0 0 24 24" width={size} xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M10 21V8a1 1 0 0 0-1-1H4a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-5a1 1 0 0 0-1-1H3"/>
|
||||||
|
<motion.path animate={controls} d="M14 3h7v7h-7z" variants={VARIANTS}/>
|
||||||
|
</svg>
|
||||||
|
</div>);
|
||||||
|
});
|
||||||
|
BlocksIcon.displayName = "BlocksIcon";
|
||||||
|
export { BlocksIcon };
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
|
||||||
|
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
function DropdownMenu({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||||
|
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props}/>;
|
||||||
|
}
|
||||||
|
function DropdownMenuPortal({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||||
|
return (<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props}/>);
|
||||||
|
}
|
||||||
|
function DropdownMenuTrigger({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||||
|
return (<DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props}/>);
|
||||||
|
}
|
||||||
|
function DropdownMenuContent({ className, sideOffset = 4, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||||
|
return (<DropdownMenuPrimitive.Portal>
|
||||||
|
<DropdownMenuPrimitive.Content data-slot="dropdown-menu-content" sideOffset={sideOffset} className={cn("z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95", className)} {...props}/>
|
||||||
|
</DropdownMenuPrimitive.Portal>);
|
||||||
|
}
|
||||||
|
function DropdownMenuGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||||
|
return (<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props}/>);
|
||||||
|
}
|
||||||
|
function DropdownMenuItem({ className, inset, variant = "default", ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean;
|
||||||
|
variant?: "default" | "destructive";
|
||||||
|
}) {
|
||||||
|
return (<DropdownMenuPrimitive.Item data-slot="dropdown-menu-item" data-inset={inset} data-variant={variant} className={cn("relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!", className)} {...props}/>);
|
||||||
|
}
|
||||||
|
function DropdownMenuCheckboxItem({ className, children, checked, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||||
|
return (<DropdownMenuPrimitive.CheckboxItem data-slot="dropdown-menu-checkbox-item" className={cn("relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className)} checked={checked} {...props}>
|
||||||
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="size-4"/>
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.CheckboxItem>);
|
||||||
|
}
|
||||||
|
function DropdownMenuRadioGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||||
|
return (<DropdownMenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props}/>);
|
||||||
|
}
|
||||||
|
function DropdownMenuRadioItem({ className, children, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||||
|
return (<DropdownMenuPrimitive.RadioItem data-slot="dropdown-menu-radio-item" className={cn("relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className)} {...props}>
|
||||||
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<CircleIcon className="size-2 fill-current"/>
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.RadioItem>);
|
||||||
|
}
|
||||||
|
function DropdownMenuLabel({ className, inset, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}) {
|
||||||
|
return (<DropdownMenuPrimitive.Label data-slot="dropdown-menu-label" data-inset={inset} className={cn("px-2 py-1.5 text-sm font-medium data-[inset]:pl-8", className)} {...props}/>);
|
||||||
|
}
|
||||||
|
function DropdownMenuSeparator({ className, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||||
|
return (<DropdownMenuPrimitive.Separator data-slot="dropdown-menu-separator" className={cn("-mx-1 my-1 h-px bg-border", className)} {...props}/>);
|
||||||
|
}
|
||||||
|
function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<"span">) {
|
||||||
|
return (<span data-slot="dropdown-menu-shortcut" className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)} {...props}/>);
|
||||||
|
}
|
||||||
|
function DropdownMenuSub({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||||
|
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props}/>;
|
||||||
|
}
|
||||||
|
function DropdownMenuSubTrigger({ className, inset, children, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}) {
|
||||||
|
return (<DropdownMenuPrimitive.SubTrigger data-slot="dropdown-menu-sub-trigger" data-inset={inset} className={cn("flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground", className)} {...props}>
|
||||||
|
{children}
|
||||||
|
<ChevronRightIcon className="ml-auto size-4"/>
|
||||||
|
</DropdownMenuPrimitive.SubTrigger>);
|
||||||
|
}
|
||||||
|
function DropdownMenuSubContent({ className, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||||
|
return (<DropdownMenuPrimitive.SubContent data-slot="dropdown-menu-sub-content" className={cn("z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95", className)} {...props}/>);
|
||||||
|
}
|
||||||
|
export { DropdownMenu, DropdownMenuPortal, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuGroup, DropdownMenuLabel, DropdownMenuItem, DropdownMenuCheckboxItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent, };
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
"use client";
|
||||||
|
import type { Variants } from "motion/react";
|
||||||
|
import { motion, useAnimation } from "motion/react";
|
||||||
|
import type { HTMLAttributes } from "react";
|
||||||
|
import { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
export interface GithubIconHandle {
|
||||||
|
startAnimation: () => void;
|
||||||
|
stopAnimation: () => void;
|
||||||
|
}
|
||||||
|
interface GithubIconProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
const BODY_VARIANTS: Variants = {
|
||||||
|
normal: {
|
||||||
|
opacity: 1,
|
||||||
|
pathLength: 1,
|
||||||
|
scale: 1,
|
||||||
|
transition: {
|
||||||
|
duration: 0.3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
animate: {
|
||||||
|
opacity: [0, 1],
|
||||||
|
pathLength: [0, 1],
|
||||||
|
scale: [0.9, 1],
|
||||||
|
transition: {
|
||||||
|
duration: 0.4,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const TAIL_VARIANTS: Variants = {
|
||||||
|
normal: {
|
||||||
|
pathLength: 1,
|
||||||
|
rotate: 0,
|
||||||
|
transition: {
|
||||||
|
duration: 0.3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
draw: {
|
||||||
|
pathLength: [0, 1],
|
||||||
|
rotate: 0,
|
||||||
|
transition: {
|
||||||
|
duration: 0.5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wag: {
|
||||||
|
pathLength: 1,
|
||||||
|
rotate: [0, -15, 15, -10, 10, -5, 5],
|
||||||
|
transition: {
|
||||||
|
duration: 2.5,
|
||||||
|
ease: "easeInOut",
|
||||||
|
repeat: Number.POSITIVE_INFINITY,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const GithubIcon = forwardRef<GithubIconHandle, GithubIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
|
||||||
|
const bodyControls = useAnimation();
|
||||||
|
const tailControls = useAnimation();
|
||||||
|
const isControlledRef = useRef(false);
|
||||||
|
useImperativeHandle(ref, () => {
|
||||||
|
isControlledRef.current = true;
|
||||||
|
return {
|
||||||
|
startAnimation: async () => {
|
||||||
|
bodyControls.start("animate");
|
||||||
|
await tailControls.start("draw");
|
||||||
|
tailControls.start("wag");
|
||||||
|
},
|
||||||
|
stopAnimation: () => {
|
||||||
|
bodyControls.start("normal");
|
||||||
|
tailControls.start("normal");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const handleMouseEnter = useCallback(async (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (isControlledRef.current) {
|
||||||
|
onMouseEnter?.(e);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
bodyControls.start("animate");
|
||||||
|
await tailControls.start("draw");
|
||||||
|
tailControls.start("wag");
|
||||||
|
}
|
||||||
|
}, [bodyControls, onMouseEnter, tailControls]);
|
||||||
|
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (isControlledRef.current) {
|
||||||
|
onMouseLeave?.(e);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
bodyControls.start("normal");
|
||||||
|
tailControls.start("normal");
|
||||||
|
}
|
||||||
|
}, [bodyControls, tailControls, onMouseLeave]);
|
||||||
|
return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
|
||||||
|
<svg fill="none" height={size} stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" viewBox="0 0 24 24" width={size} xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<motion.path animate={bodyControls} d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4" initial="normal" variants={BODY_VARIANTS}/>
|
||||||
|
<motion.path animate={tailControls} d="M9 18c-4.51 2-5-2-7-2" initial="normal" variants={TAIL_VARIANTS}/>
|
||||||
|
</svg>
|
||||||
|
</div>);
|
||||||
|
});
|
||||||
|
GithubIcon.displayName = "GithubIcon";
|
||||||
|
export { GithubIcon };
|
||||||
@@ -1,147 +1,338 @@
|
|||||||
import { useState, useCallback, useEffect } from "react";
|
import { useState, useCallback, useRef, useEffect, type MutableRefObject } from "react";
|
||||||
import { AnalyzeTrack } from "../../wailsjs/go/main/App";
|
|
||||||
import type { AnalysisResult } from "@/types/api";
|
import type { AnalysisResult } from "@/types/api";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||||
import { setSpectrumCache, getSpectrumCache, clearSpectrumCache } from "@/lib/spectrum-cache";
|
import { analyzeAudioArrayBuffer, analyzeAudioFile, analyzeSpectrumFromSamples, type AnalysisProgress, } from "@/lib/flac-analysis";
|
||||||
const STORAGE_KEY = "spotiflac_audio_analysis_state";
|
import { loadAudioAnalysisPreferences } from "@/lib/audio-analysis-preferences";
|
||||||
|
type WindowFunction = "hann" | "hamming" | "blackman" | "rectangular";
|
||||||
|
function toWindowFunction(value: string): WindowFunction {
|
||||||
|
switch (value) {
|
||||||
|
case "hamming":
|
||||||
|
case "blackman":
|
||||||
|
case "rectangular":
|
||||||
|
return value;
|
||||||
|
case "hann":
|
||||||
|
default:
|
||||||
|
return "hann";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function fileNameFromPath(filePath: string): string {
|
||||||
|
const parts = filePath.split(/[/\\]/);
|
||||||
|
return parts[parts.length - 1] || filePath;
|
||||||
|
}
|
||||||
|
function nextUiTick(): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
}
|
||||||
|
async function base64ToArrayBuffer(base64: string, shouldCancel?: () => boolean): Promise<ArrayBuffer> {
|
||||||
|
const clean = base64.includes(",") ? base64.split(",")[1] : base64;
|
||||||
|
const padding = clean.endsWith("==") ? 2 : clean.endsWith("=") ? 1 : 0;
|
||||||
|
const outputLength = Math.floor((clean.length * 3) / 4) - padding;
|
||||||
|
const bytes = new Uint8Array(outputLength);
|
||||||
|
const chunkSize = 4 * 16384;
|
||||||
|
let writeOffset = 0;
|
||||||
|
for (let offset = 0; offset < clean.length; offset += chunkSize) {
|
||||||
|
if (shouldCancel?.()) {
|
||||||
|
throw new Error("Analysis cancelled");
|
||||||
|
}
|
||||||
|
const chunk = clean.slice(offset, Math.min(clean.length, offset + chunkSize));
|
||||||
|
const binary = atob(chunk);
|
||||||
|
for (let i = 0; i < binary.length; i++) {
|
||||||
|
bytes[writeOffset++] = binary.charCodeAt(i);
|
||||||
|
}
|
||||||
|
if ((offset / chunkSize) % 4 === 0) {
|
||||||
|
await nextUiTick();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bytes.buffer;
|
||||||
|
}
|
||||||
|
let sessionResult: AnalysisResult | null = null;
|
||||||
|
let sessionSelectedFilePath = "";
|
||||||
|
let sessionError: string | null = null;
|
||||||
|
let sessionSamples: Float32Array | null = null;
|
||||||
|
interface ProgressState {
|
||||||
|
percent: number;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
const DEFAULT_PROGRESS_STATE: ProgressState = {
|
||||||
|
percent: 0,
|
||||||
|
message: "Preparing analysis...",
|
||||||
|
};
|
||||||
|
interface CancelToken {
|
||||||
|
cancelled: boolean;
|
||||||
|
}
|
||||||
|
function cancelToken(tokenRef: MutableRefObject<CancelToken | null>): void {
|
||||||
|
if (tokenRef.current) {
|
||||||
|
tokenRef.current.cancelled = true;
|
||||||
|
tokenRef.current = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function createToken(tokenRef: MutableRefObject<CancelToken | null>): CancelToken {
|
||||||
|
cancelToken(tokenRef);
|
||||||
|
const token: CancelToken = { cancelled: false };
|
||||||
|
tokenRef.current = token;
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
function isCancelledError(error: unknown): boolean {
|
||||||
|
return error instanceof Error && error.message === "Analysis cancelled";
|
||||||
|
}
|
||||||
|
function toProgressState(progress: AnalysisProgress): ProgressState {
|
||||||
|
return {
|
||||||
|
percent: Math.round(Math.max(0, Math.min(100, progress.percent))),
|
||||||
|
message: progress.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
export function useAudioAnalysis() {
|
export function useAudioAnalysis() {
|
||||||
const [analyzing, setAnalyzing] = useState(false);
|
const [analyzing, setAnalyzing] = useState(false);
|
||||||
const [result, setResult] = useState<AnalysisResult | null>(() => {
|
const [analysisProgress, setAnalysisProgress] = useState<ProgressState>(DEFAULT_PROGRESS_STATE);
|
||||||
try {
|
const [result, setResult] = useState<AnalysisResult | null>(() => sessionResult);
|
||||||
const saved = sessionStorage.getItem(STORAGE_KEY);
|
const [selectedFilePath, setSelectedFilePath] = useState(() => sessionSelectedFilePath);
|
||||||
if (saved) {
|
const [error, setError] = useState<string | null>(() => sessionError);
|
||||||
const parsed = JSON.parse(saved);
|
const [spectrumLoading, setSpectrumLoading] = useState(false);
|
||||||
if (parsed.filePath && parsed.result) {
|
const [spectrumProgress, setSpectrumProgress] = useState<ProgressState>(DEFAULT_PROGRESS_STATE);
|
||||||
return {
|
const samplesRef = useRef<Float32Array | null>(sessionSamples);
|
||||||
...parsed.result,
|
const analysisTokenRef = useRef<CancelToken | null>(null);
|
||||||
spectrum: undefined,
|
const spectrumTokenRef = useRef<CancelToken | null>(null);
|
||||||
};
|
useEffect(() => {
|
||||||
}
|
return () => {
|
||||||
}
|
cancelToken(analysisTokenRef);
|
||||||
}
|
cancelToken(spectrumTokenRef);
|
||||||
catch (err) {
|
};
|
||||||
console.error("Failed to load saved analysis state:", err);
|
}, []);
|
||||||
}
|
const setResultWithSession = useCallback((next: AnalysisResult | null) => {
|
||||||
return null;
|
sessionResult = next;
|
||||||
});
|
setResult(next);
|
||||||
const [selectedFilePath, setSelectedFilePath] = useState<string>(() => {
|
}, []);
|
||||||
try {
|
const setSelectedFilePathWithSession = useCallback((next: string) => {
|
||||||
const saved = sessionStorage.getItem(STORAGE_KEY);
|
sessionSelectedFilePath = next;
|
||||||
if (saved) {
|
setSelectedFilePath(next);
|
||||||
const parsed = JSON.parse(saved);
|
}, []);
|
||||||
return parsed.filePath || "";
|
const setErrorWithSession = useCallback((next: string | null) => {
|
||||||
}
|
sessionError = next;
|
||||||
}
|
setError(next);
|
||||||
catch (err) {
|
}, []);
|
||||||
}
|
const analyzeFile = useCallback(async (file: File) => {
|
||||||
return "";
|
if (!file) {
|
||||||
});
|
setErrorWithSession("No file provided");
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [spectrumLoading, setSpectrumLoading] = useState(() => {
|
|
||||||
try {
|
|
||||||
const saved = sessionStorage.getItem(STORAGE_KEY);
|
|
||||||
if (saved) {
|
|
||||||
const parsed = JSON.parse(saved);
|
|
||||||
if (parsed.filePath && parsed.result) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (err) {
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
const analyzeFile = useCallback(async (filePath: string) => {
|
|
||||||
if (!filePath) {
|
|
||||||
setError("No file path provided");
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
const token = createToken(analysisTokenRef);
|
||||||
|
cancelToken(spectrumTokenRef);
|
||||||
setAnalyzing(true);
|
setAnalyzing(true);
|
||||||
setError(null);
|
setAnalysisProgress({
|
||||||
setResult(null);
|
percent: 1,
|
||||||
setSelectedFilePath(filePath);
|
message: "Preparing file...",
|
||||||
|
});
|
||||||
|
setErrorWithSession(null);
|
||||||
|
setResultWithSession(null);
|
||||||
|
setSelectedFilePathWithSession(file.name);
|
||||||
try {
|
try {
|
||||||
logger.info(`Analyzing audio file: ${filePath}`);
|
logger.info(`Analyzing audio file (frontend): ${file.name}`);
|
||||||
const startTime = Date.now();
|
const start = Date.now();
|
||||||
const response = await AnalyzeTrack(filePath);
|
const prefs = loadAudioAnalysisPreferences();
|
||||||
const analysisResult: AnalysisResult = JSON.parse(response);
|
const payload = await analyzeAudioFile(file, {
|
||||||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(2);
|
fftSize: prefs.fftSize,
|
||||||
|
windowFunction: prefs.windowFunction,
|
||||||
|
}, (progress) => {
|
||||||
|
if (token.cancelled)
|
||||||
|
return;
|
||||||
|
setAnalysisProgress(toProgressState(progress));
|
||||||
|
}, () => token.cancelled);
|
||||||
|
if (token.cancelled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
samplesRef.current = payload.samples;
|
||||||
|
sessionSamples = payload.samples;
|
||||||
|
setResultWithSession(payload.result);
|
||||||
|
const elapsed = ((Date.now() - start) / 1000).toFixed(2);
|
||||||
logger.success(`Audio analysis completed in ${elapsed}s`);
|
logger.success(`Audio analysis completed in ${elapsed}s`);
|
||||||
if (analysisResult.spectrum) {
|
return payload.result;
|
||||||
setSpectrumCache(filePath, analysisResult.spectrum);
|
|
||||||
}
|
|
||||||
const { spectrum, ...detailResult } = analysisResult;
|
|
||||||
try {
|
|
||||||
sessionStorage.setItem(STORAGE_KEY, JSON.stringify({
|
|
||||||
filePath,
|
|
||||||
result: detailResult,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
catch (err) {
|
|
||||||
console.error("Failed to save analysis state:", err);
|
|
||||||
}
|
|
||||||
setResult(analysisResult);
|
|
||||||
setSpectrumLoading(false);
|
|
||||||
return analysisResult;
|
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
|
if (isCancelledError(err)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const errorMessage = err instanceof Error ? err.message : "Failed to analyze audio file";
|
const errorMessage = err instanceof Error ? err.message : "Failed to analyze audio file";
|
||||||
logger.error(`Analysis error: ${errorMessage}`);
|
logger.error(`Analysis error: ${errorMessage}`);
|
||||||
setError(errorMessage);
|
setErrorWithSession(errorMessage);
|
||||||
|
setAnalysisProgress({
|
||||||
|
percent: 0,
|
||||||
|
message: "Analysis failed",
|
||||||
|
});
|
||||||
toast.error("Audio Analysis Failed", {
|
toast.error("Audio Analysis Failed", {
|
||||||
description: errorMessage,
|
description: errorMessage,
|
||||||
});
|
});
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
setAnalyzing(false);
|
if (analysisTokenRef.current === token) {
|
||||||
|
analysisTokenRef.current = null;
|
||||||
|
setAnalyzing(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, []);
|
}, [setErrorWithSession, setResultWithSession, setSelectedFilePathWithSession]);
|
||||||
const clearResult = useCallback(() => {
|
const analyzeFilePath = useCallback(async (filePath: string) => {
|
||||||
setResult(null);
|
if (!filePath) {
|
||||||
setError(null);
|
setErrorWithSession("No file path provided");
|
||||||
setSelectedFilePath("");
|
return null;
|
||||||
|
}
|
||||||
|
const token = createToken(analysisTokenRef);
|
||||||
|
cancelToken(spectrumTokenRef);
|
||||||
|
setAnalyzing(true);
|
||||||
|
setAnalysisProgress({
|
||||||
|
percent: 1,
|
||||||
|
message: "Reading file from disk...",
|
||||||
|
});
|
||||||
|
setErrorWithSession(null);
|
||||||
|
setResultWithSession(null);
|
||||||
|
setSelectedFilePathWithSession(filePath);
|
||||||
try {
|
try {
|
||||||
sessionStorage.removeItem(STORAGE_KEY);
|
logger.info(`Analyzing audio file (frontend from path): ${filePath}`);
|
||||||
|
const start = Date.now();
|
||||||
|
const prefs = loadAudioAnalysisPreferences();
|
||||||
|
const readFileAsBase64 = (window as any)?.go?.main?.App?.ReadFileAsBase64 as ((path: string) => Promise<string>) | undefined;
|
||||||
|
if (!readFileAsBase64) {
|
||||||
|
throw new Error("ReadFileAsBase64 backend method is unavailable");
|
||||||
|
}
|
||||||
|
let base64Data = await readFileAsBase64(filePath);
|
||||||
|
if (token.cancelled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
setAnalysisProgress({
|
||||||
|
percent: 10,
|
||||||
|
message: "File loaded",
|
||||||
|
});
|
||||||
|
const arrayBuffer = await base64ToArrayBuffer(base64Data, () => token.cancelled);
|
||||||
|
base64Data = "";
|
||||||
|
if (token.cancelled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
setAnalysisProgress({
|
||||||
|
percent: 15,
|
||||||
|
message: "Preparing audio buffer...",
|
||||||
|
});
|
||||||
|
const fileName = fileNameFromPath(filePath);
|
||||||
|
const payload = await analyzeAudioArrayBuffer({
|
||||||
|
fileName,
|
||||||
|
fileSize: arrayBuffer.byteLength,
|
||||||
|
arrayBuffer,
|
||||||
|
}, {
|
||||||
|
fftSize: prefs.fftSize,
|
||||||
|
windowFunction: prefs.windowFunction,
|
||||||
|
}, (progress) => {
|
||||||
|
if (token.cancelled)
|
||||||
|
return;
|
||||||
|
const mappedPercent = 10 + (progress.percent * 0.9);
|
||||||
|
setAnalysisProgress({
|
||||||
|
percent: Math.round(Math.max(0, Math.min(100, mappedPercent))),
|
||||||
|
message: progress.message,
|
||||||
|
});
|
||||||
|
}, () => token.cancelled);
|
||||||
|
if (token.cancelled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
samplesRef.current = payload.samples;
|
||||||
|
sessionSamples = payload.samples;
|
||||||
|
setResultWithSession(payload.result);
|
||||||
|
const elapsed = ((Date.now() - start) / 1000).toFixed(2);
|
||||||
|
logger.success(`Audio analysis completed in ${elapsed}s`);
|
||||||
|
return payload.result;
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
}
|
if (isCancelledError(err)) {
|
||||||
clearSpectrumCache();
|
return null;
|
||||||
}, []);
|
|
||||||
useEffect(() => {
|
|
||||||
if (!result || !selectedFilePath || result.spectrum || !spectrumLoading) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let rafId: number;
|
|
||||||
const loadSpectrum = () => {
|
|
||||||
rafId = requestAnimationFrame(() => {
|
|
||||||
const cachedSpectrum = getSpectrumCache(selectedFilePath);
|
|
||||||
if (cachedSpectrum) {
|
|
||||||
setResult(prev => prev ? { ...prev, spectrum: cachedSpectrum } : null);
|
|
||||||
setSpectrumLoading(false);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
setSpectrumLoading(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
requestAnimationFrame(loadSpectrum);
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
if (rafId) {
|
|
||||||
cancelAnimationFrame(rafId);
|
|
||||||
}
|
}
|
||||||
};
|
const errorMessage = err instanceof Error ? err.message : "Failed to analyze audio file";
|
||||||
}, [result, selectedFilePath, spectrumLoading]);
|
logger.error(`Analysis error: ${errorMessage}`);
|
||||||
|
setErrorWithSession(errorMessage);
|
||||||
|
setAnalysisProgress({
|
||||||
|
percent: 0,
|
||||||
|
message: "Analysis failed",
|
||||||
|
});
|
||||||
|
toast.error("Audio Analysis Failed", {
|
||||||
|
description: errorMessage,
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
if (analysisTokenRef.current === token) {
|
||||||
|
analysisTokenRef.current = null;
|
||||||
|
setAnalyzing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [setErrorWithSession, setResultWithSession, setSelectedFilePathWithSession]);
|
||||||
|
const reAnalyzeSpectrum = useCallback(async (fftSize: number, windowFunction: string) => {
|
||||||
|
if (!result || !samplesRef.current)
|
||||||
|
return;
|
||||||
|
const token = createToken(spectrumTokenRef);
|
||||||
|
setSpectrumLoading(true);
|
||||||
|
setSpectrumProgress({
|
||||||
|
percent: 0,
|
||||||
|
message: "Preparing FFT...",
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||||
|
const spectrum = await analyzeSpectrumFromSamples(samplesRef.current, result.sample_rate, {
|
||||||
|
fftSize,
|
||||||
|
windowFunction: toWindowFunction(windowFunction),
|
||||||
|
}, (progress) => {
|
||||||
|
if (token.cancelled)
|
||||||
|
return;
|
||||||
|
setSpectrumProgress(toProgressState(progress));
|
||||||
|
}, () => token.cancelled);
|
||||||
|
if (token.cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setResult((prev) => {
|
||||||
|
const next = prev ? { ...prev, spectrum } : prev;
|
||||||
|
sessionResult = next;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
if (isCancelledError(err)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const errorMessage = err instanceof Error ? err.message : "Failed to re-analyze spectrum";
|
||||||
|
logger.error(`Spectrum re-analysis error: ${errorMessage}`);
|
||||||
|
setSpectrumProgress({
|
||||||
|
percent: 0,
|
||||||
|
message: "Spectrum analysis failed",
|
||||||
|
});
|
||||||
|
toast.error("Spectrum Analysis Failed", {
|
||||||
|
description: errorMessage,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
if (spectrumTokenRef.current === token) {
|
||||||
|
spectrumTokenRef.current = null;
|
||||||
|
setSpectrumLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [result]);
|
||||||
|
const clearResult = useCallback(() => {
|
||||||
|
cancelToken(analysisTokenRef);
|
||||||
|
cancelToken(spectrumTokenRef);
|
||||||
|
setAnalyzing(false);
|
||||||
|
setResultWithSession(null);
|
||||||
|
setErrorWithSession(null);
|
||||||
|
setSelectedFilePathWithSession("");
|
||||||
|
setSpectrumLoading(false);
|
||||||
|
setAnalysisProgress(DEFAULT_PROGRESS_STATE);
|
||||||
|
setSpectrumProgress(DEFAULT_PROGRESS_STATE);
|
||||||
|
samplesRef.current = null;
|
||||||
|
sessionSamples = null;
|
||||||
|
}, [setErrorWithSession, setResultWithSession, setSelectedFilePathWithSession]);
|
||||||
return {
|
return {
|
||||||
analyzing,
|
analyzing,
|
||||||
|
analysisProgress,
|
||||||
result,
|
result,
|
||||||
error,
|
error,
|
||||||
selectedFilePath,
|
selectedFilePath,
|
||||||
spectrumLoading,
|
spectrumLoading,
|
||||||
|
spectrumProgress,
|
||||||
analyzeFile,
|
analyzeFile,
|
||||||
|
analyzeFilePath,
|
||||||
|
reAnalyzeSpectrum,
|
||||||
clearResult,
|
clearResult,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export function useAvailability() {
|
|||||||
const [checkingTrackId, setCheckingTrackId] = useState<string | null>(null);
|
const [checkingTrackId, setCheckingTrackId] = useState<string | null>(null);
|
||||||
const [availabilityMap, setAvailabilityMap] = useState<Map<string, TrackAvailability>>(new Map());
|
const [availabilityMap, setAvailabilityMap] = useState<Map<string, TrackAvailability>>(new Map());
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const checkAvailability = useCallback(async (spotifyId: string, isrc?: string) => {
|
const checkAvailability = useCallback(async (spotifyId: string) => {
|
||||||
if (!spotifyId) {
|
if (!spotifyId) {
|
||||||
setError("No Spotify ID provided");
|
setError("No Spotify ID provided");
|
||||||
return null;
|
return null;
|
||||||
@@ -20,7 +20,7 @@ export function useAvailability() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
logger.info(`Checking availability for track: ${spotifyId}`);
|
logger.info(`Checking availability for track: ${spotifyId}`);
|
||||||
const response = await CheckTrackAvailability(spotifyId, isrc || "");
|
const response = await CheckTrackAvailability(spotifyId);
|
||||||
const availability: TrackAvailability = JSON.parse(response);
|
const availability: TrackAvailability = JSON.parse(response);
|
||||||
setAvailabilityMap((prev) => {
|
setAvailabilityMap((prev) => {
|
||||||
const newMap = new Map(prev);
|
const newMap = new Map(prev);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useState, useRef } from "react";
|
|||||||
import { downloadCover } from "@/lib/api";
|
import { downloadCover } from "@/lib/api";
|
||||||
import { getSettings, parseTemplate, type TemplateData } from "@/lib/settings";
|
import { getSettings, parseTemplate, type TemplateData } 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, getFirstArtist } from "@/lib/utils";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
import type { TrackMetadata } from "@/types/api";
|
import type { TrackMetadata } from "@/types/api";
|
||||||
export function useCover() {
|
export function useCover() {
|
||||||
@@ -29,17 +29,21 @@ export function useCover() {
|
|||||||
let outputDir = settings.downloadPath;
|
let outputDir = settings.downloadPath;
|
||||||
const placeholder = "__SLASH_PLACEHOLDER__";
|
const placeholder = "__SLASH_PLACEHOLDER__";
|
||||||
const yearValue = releaseDate?.substring(0, 4);
|
const yearValue = releaseDate?.substring(0, 4);
|
||||||
|
const displayArtist = settings.useFirstArtistOnly && artistName ? getFirstArtist(artistName) : artistName;
|
||||||
|
const displayAlbumArtist = settings.useFirstArtistOnly && albumArtist ? getFirstArtist(albumArtist) : albumArtist;
|
||||||
const templateData: TemplateData = {
|
const templateData: TemplateData = {
|
||||||
artist: artistName?.replace(/\//g, placeholder),
|
artist: displayArtist?.replace(/\//g, placeholder),
|
||||||
album: albumName?.replace(/\//g, placeholder),
|
album: albumName?.replace(/\//g, placeholder),
|
||||||
|
album_artist: displayAlbumArtist?.replace(/\//g, placeholder) || displayArtist?.replace(/\//g, placeholder),
|
||||||
title: trackName?.replace(/\//g, placeholder),
|
title: trackName?.replace(/\//g, placeholder),
|
||||||
track: position,
|
track: position,
|
||||||
year: yearValue,
|
year: yearValue,
|
||||||
|
date: releaseDate,
|
||||||
playlist: playlistName?.replace(/\//g, placeholder),
|
playlist: playlistName?.replace(/\//g, placeholder),
|
||||||
};
|
};
|
||||||
const folderTemplate = settings.folderTemplate || "";
|
const folderTemplate = settings.folderTemplate || "";
|
||||||
const useAlbumSubfolder = folderTemplate.includes("{album}") || folderTemplate.includes("{album_artist}") || folderTemplate.includes("{playlist}");
|
const useAlbumSubfolder = folderTemplate.includes("{album}") || folderTemplate.includes("{album_artist}") || folderTemplate.includes("{playlist}");
|
||||||
if (playlistName && (!isAlbum || !useAlbumSubfolder)) {
|
if (settings.createPlaylistFolder && playlistName && (!isAlbum || !useAlbumSubfolder)) {
|
||||||
outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os));
|
outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os));
|
||||||
}
|
}
|
||||||
if (settings.folderTemplate) {
|
if (settings.folderTemplate) {
|
||||||
@@ -55,9 +59,9 @@ export function useCover() {
|
|||||||
const response = await downloadCover({
|
const response = await downloadCover({
|
||||||
cover_url: coverUrl,
|
cover_url: coverUrl,
|
||||||
track_name: trackName,
|
track_name: trackName,
|
||||||
artist_name: artistName,
|
artist_name: displayArtist,
|
||||||
album_name: albumName || "",
|
album_name: albumName || "",
|
||||||
album_artist: albumArtist || "",
|
album_artist: displayAlbumArtist || "",
|
||||||
release_date: releaseDate || "",
|
release_date: releaseDate || "",
|
||||||
output_dir: outputDir,
|
output_dir: outputDir,
|
||||||
filename_format: settings.filenameTemplate || "{title}",
|
filename_format: settings.filenameTemplate || "{title}",
|
||||||
@@ -127,17 +131,21 @@ export function useCover() {
|
|||||||
const useAlbumTrackNumber = settings.folderTemplate?.includes("{album}") || false;
|
const useAlbumTrackNumber = settings.folderTemplate?.includes("{album}") || false;
|
||||||
const trackPosition = useAlbumTrackNumber ? (track.track_number || i + 1) : (i + 1);
|
const trackPosition = useAlbumTrackNumber ? (track.track_number || i + 1) : (i + 1);
|
||||||
const yearValue = track.release_date?.substring(0, 4);
|
const yearValue = track.release_date?.substring(0, 4);
|
||||||
|
const displayArtist = settings.useFirstArtistOnly && track.artists ? getFirstArtist(track.artists) : track.artists;
|
||||||
|
const displayAlbumArtist = settings.useFirstArtistOnly && track.album_artist ? getFirstArtist(track.album_artist) : track.album_artist;
|
||||||
const templateData: TemplateData = {
|
const templateData: TemplateData = {
|
||||||
artist: track.artists?.replace(/\//g, placeholder),
|
artist: displayArtist?.replace(/\//g, placeholder),
|
||||||
album: track.album_name?.replace(/\//g, placeholder),
|
album: track.album_name?.replace(/\//g, placeholder),
|
||||||
|
album_artist: displayAlbumArtist?.replace(/\//g, placeholder) || displayArtist?.replace(/\//g, placeholder),
|
||||||
title: track.name?.replace(/\//g, placeholder),
|
title: track.name?.replace(/\//g, placeholder),
|
||||||
track: trackPosition,
|
track: trackPosition,
|
||||||
year: yearValue,
|
year: yearValue,
|
||||||
|
date: track.release_date,
|
||||||
playlist: playlistName?.replace(/\//g, placeholder),
|
playlist: playlistName?.replace(/\//g, placeholder),
|
||||||
};
|
};
|
||||||
const folderTemplate = settings.folderTemplate || "";
|
const folderTemplate = settings.folderTemplate || "";
|
||||||
const useAlbumSubfolder = folderTemplate.includes("{album}") || folderTemplate.includes("{album_artist}") || folderTemplate.includes("{playlist}");
|
const useAlbumSubfolder = folderTemplate.includes("{album}") || folderTemplate.includes("{album_artist}") || folderTemplate.includes("{playlist}");
|
||||||
if (playlistName && (!isAlbum || !useAlbumSubfolder)) {
|
if (settings.createPlaylistFolder && playlistName && (!isAlbum || !useAlbumSubfolder)) {
|
||||||
outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os));
|
outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os));
|
||||||
}
|
}
|
||||||
if (settings.folderTemplate) {
|
if (settings.folderTemplate) {
|
||||||
@@ -153,9 +161,9 @@ export function useCover() {
|
|||||||
const response = await downloadCover({
|
const response = await downloadCover({
|
||||||
cover_url: track.images,
|
cover_url: track.images,
|
||||||
track_name: track.name,
|
track_name: track.name,
|
||||||
artist_name: track.artists,
|
artist_name: displayArtist,
|
||||||
album_name: track.album_name,
|
album_name: track.album_name,
|
||||||
album_artist: track.album_artist,
|
album_artist: displayAlbumArtist,
|
||||||
release_date: track.release_date,
|
release_date: track.release_date,
|
||||||
output_dir: outputDir,
|
output_dir: outputDir,
|
||||||
filename_format: settings.filenameTemplate || "{title}",
|
filename_format: settings.filenameTemplate || "{title}",
|
||||||
|
|||||||
+172
-114
@@ -2,16 +2,9 @@ import { useState, useRef } from "react";
|
|||||||
import { downloadTrack, fetchSpotifyMetadata } from "@/lib/api";
|
import { downloadTrack, fetchSpotifyMetadata } from "@/lib/api";
|
||||||
import { getSettings, parseTemplate, type TemplateData } from "@/lib/settings";
|
import { getSettings, parseTemplate, type TemplateData } 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, getFirstArtist } from "@/lib/utils";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
import type { TrackMetadata } from "@/types/api";
|
import type { TrackMetadata } from "@/types/api";
|
||||||
function getFirstArtist(artistString: string): string {
|
|
||||||
if (!artistString)
|
|
||||||
return artistString;
|
|
||||||
const delimiters = /[,&]|(?:\s+(?:feat\.?|ft\.?|featuring)\s+)/i;
|
|
||||||
const parts = artistString.split(delimiters);
|
|
||||||
return parts[0].trim();
|
|
||||||
}
|
|
||||||
interface CheckFileExistenceRequest {
|
interface CheckFileExistenceRequest {
|
||||||
spotify_id: string;
|
spotify_id: string;
|
||||||
track_name: string;
|
track_name: string;
|
||||||
@@ -51,7 +44,7 @@ export function useDownload(region: string) {
|
|||||||
artists: string;
|
artists: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const shouldStopDownloadRef = useRef(false);
|
const shouldStopDownloadRef = useRef(false);
|
||||||
const downloadWithAutoFallback = async (isrc: string, settings: any, trackName?: string, artistName?: string, albumName?: string, playlistName?: string, position?: number, spotifyId?: string, durationMs?: number, releaseYear?: string, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => {
|
const downloadWithAutoFallback = async (id: string, settings: any, trackName?: string, artistName?: string, albumName?: string, playlistName?: string, position?: number, spotifyId?: string, durationMs?: number, releaseYear?: string, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => {
|
||||||
const service = settings.downloader;
|
const service = settings.downloader;
|
||||||
const query = trackName && artistName ? `${trackName} ${artistName} ` : undefined;
|
const query = trackName && artistName ? `${trackName} ${artistName} ` : undefined;
|
||||||
const os = settings.operatingSystem;
|
const os = settings.operatingSystem;
|
||||||
@@ -95,6 +88,7 @@ export function useDownload(region: string) {
|
|||||||
title: trackName?.replace(/\//g, placeholder),
|
title: trackName?.replace(/\//g, placeholder),
|
||||||
track: trackNumberForTemplate,
|
track: trackNumberForTemplate,
|
||||||
year: yearValue,
|
year: yearValue,
|
||||||
|
date: releaseDate,
|
||||||
playlist: playlistName?.replace(/\//g, placeholder),
|
playlist: playlistName?.replace(/\//g, placeholder),
|
||||||
};
|
};
|
||||||
const folderTemplate = settings.folderTemplate || "";
|
const folderTemplate = settings.folderTemplate || "";
|
||||||
@@ -117,7 +111,7 @@ export function useDownload(region: string) {
|
|||||||
if (trackName && artistName) {
|
if (trackName && artistName) {
|
||||||
try {
|
try {
|
||||||
const checkRequest: CheckFileExistenceRequest = {
|
const checkRequest: CheckFileExistenceRequest = {
|
||||||
spotify_id: spotifyId || isrc,
|
spotify_id: spotifyId || id,
|
||||||
track_name: trackName,
|
track_name: trackName,
|
||||||
artist_name: displayArtist || "",
|
artist_name: displayArtist || "",
|
||||||
album_name: albumName,
|
album_name: albumName,
|
||||||
@@ -149,7 +143,7 @@ export function useDownload(region: string) {
|
|||||||
const { AddToDownloadQueue } = await import("../../wailsjs/go/main/App");
|
const { AddToDownloadQueue } = await import("../../wailsjs/go/main/App");
|
||||||
let itemID: string | undefined;
|
let itemID: string | undefined;
|
||||||
if (!fileExists) {
|
if (!fileExists) {
|
||||||
itemID = await AddToDownloadQueue(isrc, trackName || "", displayArtist || "", albumName || "");
|
itemID = await AddToDownloadQueue(id, trackName || "", displayArtist || "", albumName || "");
|
||||||
}
|
}
|
||||||
if (service === "auto") {
|
if (service === "auto") {
|
||||||
let streamingURLs: any = null;
|
let streamingURLs: any = null;
|
||||||
@@ -166,21 +160,21 @@ export function useDownload(region: string) {
|
|||||||
const durationSeconds = durationMs ? Math.round(durationMs / 1000) : undefined;
|
const durationSeconds = durationMs ? Math.round(durationMs / 1000) : undefined;
|
||||||
const order = (settings.autoOrder || "tidal-amazon-qobuz").split("-");
|
const order = (settings.autoOrder || "tidal-amazon-qobuz").split("-");
|
||||||
let lastResponse: any = { success: false, error: "No matching services found" };
|
let lastResponse: any = { success: false, error: "No matching services found" };
|
||||||
|
const fallbackErrors: string[] = [];
|
||||||
const is24Bit = (settings.autoQuality || "24") === "24";
|
const is24Bit = (settings.autoQuality || "24") === "24";
|
||||||
const tidalQuality = is24Bit ? "HI_RES_LOSSLESS" : "LOSSLESS";
|
const tidalQuality = is24Bit ? "HI_RES_LOSSLESS" : "LOSSLESS";
|
||||||
const qobuzQuality = is24Bit ? "7" : "6";
|
const qobuzQuality = is24Bit ? "27" : "6";
|
||||||
for (const s of order) {
|
for (const s of order) {
|
||||||
if (s === "tidal" && streamingURLs?.tidal_url) {
|
if (s === "tidal" && streamingURLs?.tidal_url) {
|
||||||
try {
|
try {
|
||||||
logger.debug(`trying tidal for: ${trackName} - ${artistName}`);
|
logger.debug(`trying tidal for: ${trackName} - ${artistName}`);
|
||||||
const response = await downloadTrack({
|
const response = await downloadTrack({
|
||||||
isrc,
|
|
||||||
service: "tidal",
|
service: "tidal",
|
||||||
query,
|
query,
|
||||||
track_name: trackName,
|
track_name: trackName,
|
||||||
artist_name: artistName,
|
artist_name: displayArtist,
|
||||||
album_name: albumName,
|
album_name: albumName,
|
||||||
album_artist: albumArtist,
|
album_artist: displayAlbumArtist,
|
||||||
release_date: finalReleaseDate || releaseDate,
|
release_date: finalReleaseDate || releaseDate,
|
||||||
cover_url: coverUrl,
|
cover_url: coverUrl,
|
||||||
output_dir: outputDir,
|
output_dir: outputDir,
|
||||||
@@ -201,16 +195,22 @@ export function useDownload(region: string) {
|
|||||||
spotify_total_discs: spotifyTotalDiscs,
|
spotify_total_discs: spotifyTotalDiscs,
|
||||||
copyright: copyright,
|
copyright: copyright,
|
||||||
publisher: publisher,
|
publisher: publisher,
|
||||||
|
use_first_artist_only: settings.useFirstArtistOnly,
|
||||||
|
use_single_genre: settings.useSingleGenre,
|
||||||
|
embed_genre: settings.embedGenre,
|
||||||
});
|
});
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
logger.success(`tidal: ${trackName} - ${artistName}`);
|
logger.success(`tidal: ${trackName} - ${artistName}`);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
const errMsg = response.error || response.message || "Failed";
|
||||||
|
fallbackErrors.push(`[Tidal] ${errMsg}`);
|
||||||
lastResponse = response;
|
lastResponse = response;
|
||||||
logger.warning(`tidal failed, trying next...`);
|
logger.warning(`tidal failed, trying next...`);
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
logger.error(`tidal error: ${err}`);
|
logger.error(`tidal error: ${err}`);
|
||||||
|
fallbackErrors.push(`[Tidal] ${String(err)}`);
|
||||||
lastResponse = { success: false, error: String(err) };
|
lastResponse = { success: false, error: String(err) };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -218,13 +218,12 @@ export function useDownload(region: string) {
|
|||||||
try {
|
try {
|
||||||
logger.debug(`trying amazon for: ${trackName} - ${artistName}`);
|
logger.debug(`trying amazon for: ${trackName} - ${artistName}`);
|
||||||
const response = await downloadTrack({
|
const response = await downloadTrack({
|
||||||
isrc,
|
|
||||||
service: "amazon",
|
service: "amazon",
|
||||||
query,
|
query,
|
||||||
track_name: trackName,
|
track_name: trackName,
|
||||||
artist_name: artistName,
|
artist_name: displayArtist,
|
||||||
album_name: albumName,
|
album_name: albumName,
|
||||||
album_artist: albumArtist,
|
album_artist: displayAlbumArtist,
|
||||||
release_date: finalReleaseDate || releaseDate,
|
release_date: finalReleaseDate || releaseDate,
|
||||||
cover_url: coverUrl,
|
cover_url: coverUrl,
|
||||||
output_dir: outputDir,
|
output_dir: outputDir,
|
||||||
@@ -243,16 +242,21 @@ export function useDownload(region: string) {
|
|||||||
spotify_total_discs: spotifyTotalDiscs,
|
spotify_total_discs: spotifyTotalDiscs,
|
||||||
copyright: copyright,
|
copyright: copyright,
|
||||||
publisher: publisher,
|
publisher: publisher,
|
||||||
|
use_single_genre: settings.useSingleGenre,
|
||||||
|
embed_genre: settings.embedGenre,
|
||||||
});
|
});
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
logger.success(`amazon: ${trackName} - ${artistName}`);
|
logger.success(`amazon: ${trackName} - ${artistName}`);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
const errMsg = response.error || response.message || "Failed";
|
||||||
|
fallbackErrors.push(`[Amazon] ${errMsg}`);
|
||||||
lastResponse = response;
|
lastResponse = response;
|
||||||
logger.warning(`amazon failed, trying next...`);
|
logger.warning(`amazon failed, trying next...`);
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
logger.error(`amazon error: ${err}`);
|
logger.error(`amazon error: ${err}`);
|
||||||
|
fallbackErrors.push(`[Amazon] ${String(err)}`);
|
||||||
lastResponse = { success: false, error: String(err) };
|
lastResponse = { success: false, error: String(err) };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -260,13 +264,12 @@ export function useDownload(region: string) {
|
|||||||
try {
|
try {
|
||||||
logger.debug(`trying qobuz for: ${trackName} - ${artistName}`);
|
logger.debug(`trying qobuz for: ${trackName} - ${artistName}`);
|
||||||
const response = await downloadTrack({
|
const response = await downloadTrack({
|
||||||
isrc,
|
|
||||||
service: "qobuz",
|
service: "qobuz",
|
||||||
query,
|
query,
|
||||||
track_name: trackName,
|
track_name: trackName,
|
||||||
artist_name: artistName,
|
artist_name: displayArtist,
|
||||||
album_name: albumName,
|
album_name: albumName,
|
||||||
album_artist: albumArtist,
|
album_artist: displayAlbumArtist,
|
||||||
release_date: finalReleaseDate || releaseDate,
|
release_date: finalReleaseDate || releaseDate,
|
||||||
cover_url: coverUrl,
|
cover_url: coverUrl,
|
||||||
output_dir: outputDir,
|
output_dir: outputDir,
|
||||||
@@ -285,23 +288,29 @@ export function useDownload(region: string) {
|
|||||||
spotify_total_discs: spotifyTotalDiscs,
|
spotify_total_discs: spotifyTotalDiscs,
|
||||||
copyright: copyright,
|
copyright: copyright,
|
||||||
publisher: publisher,
|
publisher: publisher,
|
||||||
|
use_single_genre: settings.useSingleGenre,
|
||||||
|
embed_genre: settings.embedGenre,
|
||||||
});
|
});
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
logger.success(`qobuz: ${trackName} - ${artistName}`);
|
logger.success(`qobuz: ${trackName} - ${artistName}`);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
const errMsg = response.error || response.message || "Failed";
|
||||||
|
fallbackErrors.push(`[Qobuz] ${errMsg}`);
|
||||||
lastResponse = response;
|
lastResponse = response;
|
||||||
logger.warning(`qobuz failed, trying next...`);
|
logger.warning(`qobuz failed, trying next...`);
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
logger.error(`qobuz error: ${err}`);
|
logger.error(`qobuz error: ${err}`);
|
||||||
|
fallbackErrors.push(`[Qobuz] ${String(err)}`);
|
||||||
lastResponse = { success: false, error: String(err) };
|
lastResponse = { success: false, error: String(err) };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (itemID) {
|
if (itemID) {
|
||||||
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App");
|
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App");
|
||||||
await MarkDownloadItemFailed(itemID, lastResponse.error || "All services failed");
|
const finalError = fallbackErrors.length > 0 ? fallbackErrors.join(" | ") : (lastResponse.error || "All services failed");
|
||||||
|
await MarkDownloadItemFailed(itemID, finalError);
|
||||||
}
|
}
|
||||||
return lastResponse;
|
return lastResponse;
|
||||||
}
|
}
|
||||||
@@ -313,14 +322,17 @@ export function useDownload(region: string) {
|
|||||||
else if (service === "qobuz") {
|
else if (service === "qobuz") {
|
||||||
audioFormat = settings.qobuzQuality || "6";
|
audioFormat = settings.qobuzQuality || "6";
|
||||||
}
|
}
|
||||||
|
else if (service === "deezer") {
|
||||||
|
audioFormat = "flac";
|
||||||
|
}
|
||||||
|
logger.debug(`trying ${service} for: ${trackName} - ${artistName}`);
|
||||||
const singleServiceResponse = await downloadTrack({
|
const singleServiceResponse = await downloadTrack({
|
||||||
isrc,
|
|
||||||
service: service as "tidal" | "qobuz" | "amazon",
|
service: service as "tidal" | "qobuz" | "amazon",
|
||||||
query,
|
query,
|
||||||
track_name: trackName,
|
track_name: trackName,
|
||||||
artist_name: artistName,
|
artist_name: displayArtist,
|
||||||
album_name: albumName,
|
album_name: albumName,
|
||||||
album_artist: albumArtist,
|
album_artist: displayAlbumArtist,
|
||||||
release_date: finalReleaseDate || releaseDate,
|
release_date: finalReleaseDate || releaseDate,
|
||||||
cover_url: coverUrl,
|
cover_url: coverUrl,
|
||||||
output_dir: outputDir,
|
output_dir: outputDir,
|
||||||
@@ -340,6 +352,8 @@ export function useDownload(region: string) {
|
|||||||
spotify_total_discs: spotifyTotalDiscs,
|
spotify_total_discs: spotifyTotalDiscs,
|
||||||
copyright: copyright,
|
copyright: copyright,
|
||||||
publisher: publisher,
|
publisher: publisher,
|
||||||
|
use_single_genre: settings.useSingleGenre,
|
||||||
|
embed_genre: settings.embedGenre,
|
||||||
});
|
});
|
||||||
if (!singleServiceResponse.success && itemID) {
|
if (!singleServiceResponse.success && itemID) {
|
||||||
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App");
|
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App");
|
||||||
@@ -347,7 +361,7 @@ export function useDownload(region: string) {
|
|||||||
}
|
}
|
||||||
return singleServiceResponse;
|
return singleServiceResponse;
|
||||||
};
|
};
|
||||||
const downloadWithItemID = async (isrc: string, settings: any, itemID: string, trackName?: string, artistName?: string, albumName?: string, folderName?: string, position?: number, spotifyId?: string, durationMs?: number, isAlbum?: boolean, releaseYear?: string, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => {
|
const downloadWithItemID = async (settings: any, itemID: string, trackName?: string, artistName?: string, albumName?: string, folderName?: string, position?: number, spotifyId?: string, durationMs?: number, isAlbum?: boolean, releaseYear?: string, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => {
|
||||||
const service = settings.downloader;
|
const service = settings.downloader;
|
||||||
const query = trackName && artistName ? `${trackName} ${artistName}` : undefined;
|
const query = trackName && artistName ? `${trackName} ${artistName}` : undefined;
|
||||||
const os = settings.operatingSystem;
|
const os = settings.operatingSystem;
|
||||||
@@ -375,16 +389,20 @@ export function useDownload(region: string) {
|
|||||||
const yearValue = releaseYear || finalReleaseDate?.substring(0, 4);
|
const yearValue = releaseYear || finalReleaseDate?.substring(0, 4);
|
||||||
const hasSubfolder = settings.folderTemplate && settings.folderTemplate.trim() !== "";
|
const hasSubfolder = settings.folderTemplate && settings.folderTemplate.trim() !== "";
|
||||||
const trackNumberForTemplate = (hasSubfolder && finalTrackNumber > 0) ? finalTrackNumber : (position || 0);
|
const trackNumberForTemplate = (hasSubfolder && finalTrackNumber > 0) ? finalTrackNumber : (position || 0);
|
||||||
if (hasSubfolder) {
|
const displayArtist = settings.useFirstArtistOnly && artistName
|
||||||
useAlbumTrackNumber = true;
|
? getFirstArtist(artistName)
|
||||||
}
|
: artistName;
|
||||||
|
const displayAlbumArtist = settings.useFirstArtistOnly && albumArtist
|
||||||
|
? getFirstArtist(albumArtist)
|
||||||
|
: albumArtist;
|
||||||
const templateData: TemplateData = {
|
const templateData: TemplateData = {
|
||||||
artist: artistName?.replace(/\//g, placeholder),
|
artist: displayArtist?.replace(/\//g, placeholder),
|
||||||
album: albumName?.replace(/\//g, placeholder),
|
album: albumName?.replace(/\//g, placeholder),
|
||||||
album_artist: albumArtist?.replace(/\//g, placeholder) || artistName?.replace(/\//g, placeholder),
|
album_artist: displayAlbumArtist?.replace(/\//g, placeholder) || displayArtist?.replace(/\//g, placeholder),
|
||||||
title: trackName?.replace(/\//g, placeholder),
|
title: trackName?.replace(/\//g, placeholder),
|
||||||
track: trackNumberForTemplate,
|
track: trackNumberForTemplate,
|
||||||
year: yearValue,
|
year: yearValue,
|
||||||
|
date: releaseDate,
|
||||||
playlist: folderName?.replace(/\//g, placeholder),
|
playlist: folderName?.replace(/\//g, placeholder),
|
||||||
};
|
};
|
||||||
const folderTemplate = settings.folderTemplate || "";
|
const folderTemplate = settings.folderTemplate || "";
|
||||||
@@ -417,20 +435,21 @@ export function useDownload(region: string) {
|
|||||||
const durationSeconds = durationMs ? Math.round(durationMs / 1000) : undefined;
|
const durationSeconds = durationMs ? Math.round(durationMs / 1000) : undefined;
|
||||||
const order = (settings.autoOrder || "tidal-amazon-qobuz").split("-");
|
const order = (settings.autoOrder || "tidal-amazon-qobuz").split("-");
|
||||||
let lastResponse: any = { success: false, error: "No matching services found" };
|
let lastResponse: any = { success: false, error: "No matching services found" };
|
||||||
|
const fallbackErrors: string[] = [];
|
||||||
const is24Bit = (settings.autoQuality || "24") === "24";
|
const is24Bit = (settings.autoQuality || "24") === "24";
|
||||||
const tidalQuality = is24Bit ? "HI_RES_LOSSLESS" : "LOSSLESS";
|
const tidalQuality = is24Bit ? "HI_RES_LOSSLESS" : "LOSSLESS";
|
||||||
const qobuzQuality = is24Bit ? "7" : "6";
|
const qobuzQuality = is24Bit ? "27" : "6";
|
||||||
for (const s of order) {
|
for (const s of order) {
|
||||||
if (s === "tidal" && streamingURLs?.tidal_url) {
|
if (s === "tidal" && streamingURLs?.tidal_url) {
|
||||||
try {
|
try {
|
||||||
|
logger.debug(`trying tidal for: ${trackName} - ${artistName}`);
|
||||||
const response = await downloadTrack({
|
const response = await downloadTrack({
|
||||||
isrc,
|
|
||||||
service: "tidal",
|
service: "tidal",
|
||||||
query,
|
query,
|
||||||
track_name: trackName,
|
track_name: trackName,
|
||||||
artist_name: artistName,
|
artist_name: displayArtist,
|
||||||
album_name: albumName,
|
album_name: albumName,
|
||||||
album_artist: albumArtist,
|
album_artist: displayAlbumArtist,
|
||||||
release_date: finalReleaseDate || releaseDate,
|
release_date: finalReleaseDate || releaseDate,
|
||||||
cover_url: coverUrl,
|
cover_url: coverUrl,
|
||||||
output_dir: outputDir,
|
output_dir: outputDir,
|
||||||
@@ -451,27 +470,35 @@ export function useDownload(region: string) {
|
|||||||
spotify_total_discs: spotifyTotalDiscs,
|
spotify_total_discs: spotifyTotalDiscs,
|
||||||
copyright: copyright,
|
copyright: copyright,
|
||||||
publisher: publisher,
|
publisher: publisher,
|
||||||
|
use_first_artist_only: settings.useFirstArtistOnly,
|
||||||
|
use_single_genre: settings.useSingleGenre,
|
||||||
|
embed_genre: settings.embedGenre,
|
||||||
});
|
});
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
|
logger.success(`tidal: ${trackName} - ${artistName}`);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
const errMsg = response.error || response.message || "Failed";
|
||||||
|
fallbackErrors.push(`[Tidal] ${errMsg}`);
|
||||||
lastResponse = response;
|
lastResponse = response;
|
||||||
|
logger.warning(`tidal failed, trying next...`);
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
console.error("Tidal error:", err);
|
logger.error(`tidal error: ${err}`);
|
||||||
|
fallbackErrors.push(`[Tidal] ${String(err)}`);
|
||||||
lastResponse = { success: false, error: String(err) };
|
lastResponse = { success: false, error: String(err) };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (s === "amazon" && streamingURLs?.amazon_url) {
|
else if (s === "amazon" && streamingURLs?.amazon_url) {
|
||||||
try {
|
try {
|
||||||
|
logger.debug(`trying amazon for: ${trackName} - ${artistName}`);
|
||||||
const response = await downloadTrack({
|
const response = await downloadTrack({
|
||||||
isrc,
|
|
||||||
service: "amazon",
|
service: "amazon",
|
||||||
query,
|
query,
|
||||||
track_name: trackName,
|
track_name: trackName,
|
||||||
artist_name: artistName,
|
artist_name: displayArtist,
|
||||||
album_name: albumName,
|
album_name: albumName,
|
||||||
album_artist: albumArtist,
|
album_artist: displayAlbumArtist,
|
||||||
release_date: finalReleaseDate || releaseDate,
|
release_date: finalReleaseDate || releaseDate,
|
||||||
cover_url: coverUrl,
|
cover_url: coverUrl,
|
||||||
output_dir: outputDir,
|
output_dir: outputDir,
|
||||||
@@ -490,27 +517,35 @@ export function useDownload(region: string) {
|
|||||||
spotify_total_discs: spotifyTotalDiscs,
|
spotify_total_discs: spotifyTotalDiscs,
|
||||||
copyright: copyright,
|
copyright: copyright,
|
||||||
publisher: publisher,
|
publisher: publisher,
|
||||||
|
use_first_artist_only: settings.useFirstArtistOnly,
|
||||||
|
use_single_genre: settings.useSingleGenre,
|
||||||
|
embed_genre: settings.embedGenre,
|
||||||
});
|
});
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
|
logger.success(`amazon: ${trackName} - ${artistName}`);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
const errMsg = response.error || response.message || "Failed";
|
||||||
|
fallbackErrors.push(`[Amazon] ${errMsg}`);
|
||||||
lastResponse = response;
|
lastResponse = response;
|
||||||
|
logger.warning(`amazon failed, trying next...`);
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
console.error("Amazon error:", err);
|
logger.error(`amazon error: ${err}`);
|
||||||
|
fallbackErrors.push(`[Amazon] ${String(err)}`);
|
||||||
lastResponse = { success: false, error: String(err) };
|
lastResponse = { success: false, error: String(err) };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (s === "qobuz") {
|
else if (s === "qobuz") {
|
||||||
try {
|
try {
|
||||||
|
logger.debug(`trying qobuz for: ${trackName} - ${artistName}`);
|
||||||
const response = await downloadTrack({
|
const response = await downloadTrack({
|
||||||
isrc,
|
|
||||||
service: "qobuz",
|
service: "qobuz",
|
||||||
query,
|
query,
|
||||||
track_name: trackName,
|
track_name: trackName,
|
||||||
artist_name: artistName,
|
artist_name: displayArtist,
|
||||||
album_name: albumName,
|
album_name: albumName,
|
||||||
album_artist: albumArtist,
|
album_artist: displayAlbumArtist,
|
||||||
release_date: finalReleaseDate || releaseDate,
|
release_date: finalReleaseDate || releaseDate,
|
||||||
cover_url: coverUrl,
|
cover_url: coverUrl,
|
||||||
output_dir: outputDir,
|
output_dir: outputDir,
|
||||||
@@ -530,21 +565,30 @@ export function useDownload(region: string) {
|
|||||||
spotify_total_discs: spotifyTotalDiscs,
|
spotify_total_discs: spotifyTotalDiscs,
|
||||||
copyright: copyright,
|
copyright: copyright,
|
||||||
publisher: publisher,
|
publisher: publisher,
|
||||||
|
use_first_artist_only: settings.useFirstArtistOnly,
|
||||||
|
use_single_genre: settings.useSingleGenre,
|
||||||
|
embed_genre: settings.embedGenre,
|
||||||
});
|
});
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
|
logger.success(`qobuz: ${trackName} - ${artistName}`);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
const errMsg = response.error || response.message || "Failed";
|
||||||
|
fallbackErrors.push(`[Qobuz] ${errMsg}`);
|
||||||
lastResponse = response;
|
lastResponse = response;
|
||||||
|
logger.warning(`qobuz failed, trying next...`);
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
console.error("Qobuz error:", err);
|
logger.error(`qobuz error: ${err}`);
|
||||||
|
fallbackErrors.push(`[Qobuz] ${String(err)}`);
|
||||||
lastResponse = { success: false, error: String(err) };
|
lastResponse = { success: false, error: String(err) };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!lastResponse.success && itemID) {
|
if (!lastResponse.success && itemID) {
|
||||||
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App");
|
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App");
|
||||||
await MarkDownloadItemFailed(itemID, lastResponse.error || "All services failed");
|
const finalError = fallbackErrors.length > 0 ? fallbackErrors.join(" | ") : (lastResponse.error || "All services failed");
|
||||||
|
await MarkDownloadItemFailed(itemID, finalError);
|
||||||
}
|
}
|
||||||
return lastResponse;
|
return lastResponse;
|
||||||
}
|
}
|
||||||
@@ -557,13 +601,12 @@ export function useDownload(region: string) {
|
|||||||
audioFormat = settings.qobuzQuality || "6";
|
audioFormat = settings.qobuzQuality || "6";
|
||||||
}
|
}
|
||||||
const singleServiceResponse = await downloadTrack({
|
const singleServiceResponse = await downloadTrack({
|
||||||
isrc,
|
|
||||||
service: service as "tidal" | "qobuz" | "amazon",
|
service: service as "tidal" | "qobuz" | "amazon",
|
||||||
query,
|
query,
|
||||||
track_name: trackName,
|
track_name: trackName,
|
||||||
artist_name: artistName,
|
artist_name: displayArtist,
|
||||||
album_name: albumName,
|
album_name: albumName,
|
||||||
album_artist: albumArtist,
|
album_artist: displayAlbumArtist,
|
||||||
release_date: finalReleaseDate || releaseDate,
|
release_date: finalReleaseDate || releaseDate,
|
||||||
cover_url: coverUrl,
|
cover_url: coverUrl,
|
||||||
output_dir: outputDir,
|
output_dir: outputDir,
|
||||||
@@ -583,6 +626,9 @@ export function useDownload(region: string) {
|
|||||||
spotify_total_discs: spotifyTotalDiscs,
|
spotify_total_discs: spotifyTotalDiscs,
|
||||||
copyright: copyright,
|
copyright: copyright,
|
||||||
publisher: publisher,
|
publisher: publisher,
|
||||||
|
use_first_artist_only: settings.useFirstArtistOnly,
|
||||||
|
use_single_genre: settings.useSingleGenre,
|
||||||
|
embed_genre: settings.embedGenre,
|
||||||
});
|
});
|
||||||
if (!singleServiceResponse.success && itemID) {
|
if (!singleServiceResponse.success && itemID) {
|
||||||
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App");
|
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App");
|
||||||
@@ -590,40 +636,41 @@ export function useDownload(region: string) {
|
|||||||
}
|
}
|
||||||
return singleServiceResponse;
|
return singleServiceResponse;
|
||||||
};
|
};
|
||||||
const handleDownloadTrack = async (isrc: string, trackName?: string, artistName?: string, albumName?: string, spotifyId?: string, playlistName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => {
|
const handleDownloadTrack = async (id: string, trackName?: string, artistName?: string, albumName?: string, spotifyId?: string, playlistName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => {
|
||||||
if (!isrc) {
|
if (!id) {
|
||||||
toast.error("No ISRC found for this track");
|
toast.error("No ID found for this track");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
logger.info(`starting download: ${trackName} - ${artistName}`);
|
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
setDownloadingTrack(isrc);
|
const displayArtist = settings.useFirstArtistOnly && artistName ? getFirstArtist(artistName) : artistName;
|
||||||
|
logger.info(`starting download: ${trackName} - ${displayArtist}`);
|
||||||
|
setDownloadingTrack(id);
|
||||||
try {
|
try {
|
||||||
const releaseYear = releaseDate?.substring(0, 4);
|
const releaseYear = releaseDate?.substring(0, 4);
|
||||||
const response = await downloadWithAutoFallback(isrc, settings, trackName, artistName, albumName, playlistName, position, spotifyId, durationMs, releaseYear, albumArtist || "", releaseDate, coverUrl, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, copyright, publisher);
|
const response = await downloadWithAutoFallback(id, settings, trackName, artistName, albumName, playlistName, position, spotifyId, durationMs, releaseYear, albumArtist || "", releaseDate, coverUrl, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, copyright, publisher);
|
||||||
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));
|
setSkippedTracks((prev) => new Set(prev).add(id));
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
toast.success(response.message);
|
toast.success(response.message);
|
||||||
}
|
}
|
||||||
setDownloadedTracks((prev) => new Set(prev).add(isrc));
|
setDownloadedTracks((prev) => new Set(prev).add(id));
|
||||||
setFailedTracks((prev) => {
|
setFailedTracks((prev) => {
|
||||||
const newSet = new Set(prev);
|
const newSet = new Set(prev);
|
||||||
newSet.delete(isrc);
|
newSet.delete(id);
|
||||||
return newSet;
|
return newSet;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
toast.error(response.error || "Download failed");
|
toast.error(response.error || "Download failed");
|
||||||
setFailedTracks((prev) => new Set(prev).add(isrc));
|
setFailedTracks((prev) => new Set(prev).add(id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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));
|
setFailedTracks((prev) => new Set(prev).add(id));
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
setDownloadingTrack(null);
|
setDownloadingTrack(null);
|
||||||
@@ -646,18 +693,20 @@ export function useDownload(region: string) {
|
|||||||
outputDir = joinPath(os, outputDir, sanitizePath(folderName.replace(/\//g, " "), os));
|
outputDir = joinPath(os, outputDir, sanitizePath(folderName.replace(/\//g, " "), os));
|
||||||
}
|
}
|
||||||
const selectedTrackObjects = selectedTracks
|
const selectedTrackObjects = selectedTracks
|
||||||
.map((isrc) => allTracks.find((t) => t.isrc === isrc))
|
.map((id) => allTracks.find((t) => t.spotify_id === id))
|
||||||
.filter((t): t is TrackMetadata => t !== undefined);
|
.filter((t): t is TrackMetadata => t !== undefined);
|
||||||
logger.info(`checking existing files in parallel...`);
|
logger.info(`checking existing files in parallel...`);
|
||||||
const useAlbumTrackNumber = settings.folderTemplate?.includes("{album}") || false;
|
const useAlbumTrackNumber = settings.folderTemplate?.includes("{album}") || false;
|
||||||
const audioFormat = "flac";
|
const audioFormat = "flac";
|
||||||
const existenceChecks = selectedTrackObjects.map((track, index) => {
|
const existenceChecks = selectedTrackObjects.map((track, index) => {
|
||||||
|
const displayArtist = settings.useFirstArtistOnly && track.artists ? getFirstArtist(track.artists) : track.artists;
|
||||||
|
const displayAlbumArtist = settings.useFirstArtistOnly && track.album_artist ? getFirstArtist(track.album_artist) : track.album_artist;
|
||||||
return {
|
return {
|
||||||
spotify_id: track.spotify_id || track.isrc,
|
spotify_id: track.spotify_id || "",
|
||||||
track_name: track.name || "",
|
track_name: track.name || "",
|
||||||
artist_name: track.artists || "",
|
artist_name: displayArtist || "",
|
||||||
album_name: track.album_name || "",
|
album_name: track.album_name || "",
|
||||||
album_artist: track.album_artist || "",
|
album_artist: displayAlbumArtist || "",
|
||||||
release_date: track.release_date || "",
|
release_date: track.release_date || "",
|
||||||
track_number: track.track_number || 0,
|
track_number: track.track_number || 0,
|
||||||
disc_number: track.disc_number || 0,
|
disc_number: track.disc_number || 0,
|
||||||
@@ -682,20 +731,23 @@ export function useDownload(region: string) {
|
|||||||
logger.info(`found ${existingSpotifyIDs.size} existing files`);
|
logger.info(`found ${existingSpotifyIDs.size} existing files`);
|
||||||
const { AddToDownloadQueue } = await import("../../wailsjs/go/main/App");
|
const { AddToDownloadQueue } = await import("../../wailsjs/go/main/App");
|
||||||
const itemIDs: string[] = [];
|
const itemIDs: string[] = [];
|
||||||
for (const isrc of selectedTracks) {
|
for (const id of selectedTracks) {
|
||||||
const track = allTracks.find((t) => t.isrc === isrc);
|
const track = allTracks.find((t) => t.spotify_id === id);
|
||||||
const trackID = track?.spotify_id || isrc;
|
if (!track)
|
||||||
const itemID = await AddToDownloadQueue(trackID, track?.name || "", track?.artists || "", track?.album_name || "");
|
continue;
|
||||||
|
const trackID = track.spotify_id || id;
|
||||||
|
const displayArtist = settings.useFirstArtistOnly && track.artists ? getFirstArtist(track.artists) : track.artists;
|
||||||
|
const itemID = await AddToDownloadQueue(trackID, track.name || "", displayArtist || "", track.album_name || "");
|
||||||
itemIDs.push(itemID);
|
itemIDs.push(itemID);
|
||||||
if (existingSpotifyIDs.has(trackID)) {
|
if (existingSpotifyIDs.has(trackID)) {
|
||||||
const filePath = existingFilePaths.get(trackID) || "";
|
const filePath = existingFilePaths.get(trackID) || "";
|
||||||
setTimeout(() => SkipDownloadItem(itemID, filePath), 10);
|
setTimeout(() => SkipDownloadItem(itemID, filePath), 10);
|
||||||
setSkippedTracks((prev) => new Set(prev).add(isrc));
|
setSkippedTracks((prev) => new Set(prev).add(id));
|
||||||
setDownloadedTracks((prev) => new Set(prev).add(isrc));
|
setDownloadedTracks((prev) => new Set(prev).add(id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const tracksToDownload = selectedTrackObjects.filter((track) => {
|
const tracksToDownload = selectedTrackObjects.filter((track) => {
|
||||||
const trackID = track.spotify_id || track.isrc;
|
const trackID = track.spotify_id || "";
|
||||||
return !existingSpotifyIDs.has(trackID);
|
return !existingSpotifyIDs.has(trackID);
|
||||||
});
|
});
|
||||||
let successCount = 0;
|
let successCount = 0;
|
||||||
@@ -709,45 +761,46 @@ export function useDownload(region: string) {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
const track = tracksToDownload[i];
|
const track = tracksToDownload[i];
|
||||||
const isrc = track.isrc;
|
const id = track.spotify_id || "";
|
||||||
const originalIndex = selectedTracks.indexOf(isrc);
|
const originalIndex = selectedTracks.indexOf(id);
|
||||||
const itemID = itemIDs[originalIndex];
|
const itemID = itemIDs[originalIndex];
|
||||||
setDownloadingTrack(isrc);
|
setDownloadingTrack(id);
|
||||||
setCurrentDownloadInfo({ name: track.name, artists: track.artists });
|
const displayArtist = settings.useFirstArtistOnly && track.artists ? getFirstArtist(track.artists) : track.artists;
|
||||||
|
setCurrentDownloadInfo({ name: track.name, artists: displayArtist || "" });
|
||||||
try {
|
try {
|
||||||
const releaseYear = track.release_date?.substring(0, 4);
|
const releaseYear = track.release_date?.substring(0, 4);
|
||||||
const response = await downloadWithItemID(isrc, settings, itemID, track.name, track.artists, track.album_name, folderName, originalIndex + 1, track.spotify_id, track.duration_ms, isAlbum, releaseYear, track.album_artist || "", track.release_date, track.images, track.track_number, track.disc_number, track.total_tracks, track.total_discs, track.copyright, track.publisher);
|
const response = await downloadWithItemID(settings, itemID, track.name, track.artists, track.album_name, folderName, originalIndex + 1, track.spotify_id, track.duration_ms, isAlbum, releaseYear, track.album_artist || "", track.release_date, track.images, track.track_number, track.disc_number, track.total_tracks, track.total_discs, track.copyright, track.publisher);
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
if (response.already_exists) {
|
if (response.already_exists) {
|
||||||
skippedCount++;
|
skippedCount++;
|
||||||
logger.info(`skipped: ${track.name} - ${track.artists} (already exists)`);
|
logger.info(`skipped: ${track.name} - ${displayArtist} (already exists)`);
|
||||||
setSkippedTracks((prev) => new Set(prev).add(isrc));
|
setSkippedTracks((prev) => new Set(prev).add(id));
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
successCount++;
|
successCount++;
|
||||||
logger.success(`downloaded: ${track.name} - ${track.artists}`);
|
logger.success(`downloaded: ${track.name} - ${displayArtist}`);
|
||||||
}
|
}
|
||||||
if (response.file) {
|
if (response.file) {
|
||||||
finalFilePaths.set(isrc, response.file);
|
finalFilePaths.set(id, response.file);
|
||||||
finalFilePaths.set(track.spotify_id || isrc, response.file);
|
finalFilePaths.set(track.spotify_id || id, response.file);
|
||||||
}
|
}
|
||||||
setDownloadedTracks((prev) => new Set(prev).add(isrc));
|
setDownloadedTracks((prev) => new Set(prev).add(id));
|
||||||
setFailedTracks((prev) => {
|
setFailedTracks((prev) => {
|
||||||
const newSet = new Set(prev);
|
const newSet = new Set(prev);
|
||||||
newSet.delete(isrc);
|
newSet.delete(id);
|
||||||
return newSet;
|
return newSet;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
errorCount++;
|
errorCount++;
|
||||||
logger.error(`failed: ${track.name} - ${track.artists}`);
|
logger.error(`failed: ${track.name} - ${displayArtist}`);
|
||||||
setFailedTracks((prev) => new Set(prev).add(isrc));
|
setFailedTracks((prev) => new Set(prev).add(id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
errorCount++;
|
errorCount++;
|
||||||
logger.error(`error: ${track.name} - ${err}`);
|
logger.error(`error: ${track.name} - ${err}`);
|
||||||
setFailedTracks((prev) => new Set(prev).add(isrc));
|
setFailedTracks((prev) => new Set(prev).add(id));
|
||||||
if (itemID) {
|
if (itemID) {
|
||||||
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App");
|
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App");
|
||||||
await MarkDownloadItemFailed(itemID, err instanceof Error ? err.message : String(err));
|
await MarkDownloadItemFailed(itemID, err instanceof Error ? err.message : String(err));
|
||||||
@@ -764,7 +817,7 @@ export function useDownload(region: string) {
|
|||||||
const { CancelAllQueuedItems } = await import("../../wailsjs/go/main/App");
|
const { CancelAllQueuedItems } = await import("../../wailsjs/go/main/App");
|
||||||
await CancelAllQueuedItems();
|
await CancelAllQueuedItems();
|
||||||
if (settings.createM3u8File && folderName) {
|
if (settings.createM3u8File && folderName) {
|
||||||
const paths = selectedTrackObjects.map((t) => finalFilePaths.get(t.spotify_id || t.isrc) || "").filter((p) => p !== "");
|
const paths = selectedTrackObjects.map((t) => finalFilePaths.get(t.spotify_id || "") || "").filter((p) => p !== "");
|
||||||
if (paths.length > 0) {
|
if (paths.length > 0) {
|
||||||
try {
|
try {
|
||||||
logger.info(`creating m3u8 playlist: ${folderName}`);
|
logger.info(`creating m3u8 playlist: ${folderName}`);
|
||||||
@@ -798,12 +851,12 @@ export function useDownload(region: string) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
const handleDownloadAll = async (tracks: TrackMetadata[], folderName?: string, isAlbum?: boolean) => {
|
const handleDownloadAll = async (tracks: TrackMetadata[], folderName?: string, isAlbum?: boolean) => {
|
||||||
const tracksWithIsrc = tracks.filter((track) => track.isrc);
|
const tracksWithId = tracks.filter((track) => track.spotify_id);
|
||||||
if (tracksWithIsrc.length === 0) {
|
if (tracksWithId.length === 0) {
|
||||||
toast.error("No tracks available for download");
|
toast.error("No tracks available for download");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
logger.info(`starting batch download: ${tracksWithIsrc.length} tracks`);
|
logger.info(`starting batch download: ${tracksWithId.length} tracks`);
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
setIsDownloading(true);
|
setIsDownloading(true);
|
||||||
setBulkDownloadType("all");
|
setBulkDownloadType("all");
|
||||||
@@ -817,13 +870,15 @@ export function useDownload(region: string) {
|
|||||||
logger.info(`checking existing files in parallel...`);
|
logger.info(`checking existing files in parallel...`);
|
||||||
const useAlbumTrackNumber = settings.folderTemplate?.includes("{album}") || false;
|
const useAlbumTrackNumber = settings.folderTemplate?.includes("{album}") || false;
|
||||||
const audioFormat = "flac";
|
const audioFormat = "flac";
|
||||||
const existenceChecks = tracksWithIsrc.map((track, index) => {
|
const existenceChecks = tracksWithId.map((track, index) => {
|
||||||
|
const displayArtist = settings.useFirstArtistOnly && track.artists ? getFirstArtist(track.artists) : track.artists;
|
||||||
|
const displayAlbumArtist = settings.useFirstArtistOnly && track.album_artist ? getFirstArtist(track.album_artist) : track.album_artist;
|
||||||
return {
|
return {
|
||||||
spotify_id: track.spotify_id || track.isrc,
|
spotify_id: track.spotify_id || "",
|
||||||
track_name: track.name || "",
|
track_name: track.name || "",
|
||||||
artist_name: track.artists || "",
|
artist_name: displayArtist || "",
|
||||||
album_name: track.album_name || "",
|
album_name: track.album_name || "",
|
||||||
album_artist: track.album_artist || "",
|
album_artist: displayAlbumArtist || "",
|
||||||
release_date: track.release_date || "",
|
release_date: track.release_date || "",
|
||||||
track_number: track.track_number || 0,
|
track_number: track.track_number || 0,
|
||||||
disc_number: track.disc_number || 0,
|
disc_number: track.disc_number || 0,
|
||||||
@@ -835,7 +890,7 @@ export function useDownload(region: string) {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
const existenceResults = await CheckFilesExistence(outputDir, settings.downloadPath, existenceChecks);
|
const existenceResults = await CheckFilesExistence(outputDir, settings.downloadPath, existenceChecks);
|
||||||
const finalFilePaths: string[] = new Array(tracksWithIsrc.length).fill("");
|
const finalFilePaths: string[] = new Array(tracksWithId.length).fill("");
|
||||||
const existingSpotifyIDs = new Set<string>();
|
const existingSpotifyIDs = new Set<string>();
|
||||||
const existingFilePaths = new Map<string, string>();
|
const existingFilePaths = new Map<string, string>();
|
||||||
for (let i = 0; i < existenceResults.length; i++) {
|
for (let i = 0; i < existenceResults.length; i++) {
|
||||||
@@ -849,25 +904,26 @@ export function useDownload(region: string) {
|
|||||||
logger.info(`found ${existingSpotifyIDs.size} existing files`);
|
logger.info(`found ${existingSpotifyIDs.size} existing files`);
|
||||||
const { AddToDownloadQueue } = await import("../../wailsjs/go/main/App");
|
const { AddToDownloadQueue } = await import("../../wailsjs/go/main/App");
|
||||||
const itemIDs: string[] = [];
|
const itemIDs: string[] = [];
|
||||||
for (const track of tracksWithIsrc) {
|
for (const track of tracksWithId) {
|
||||||
const itemID = await AddToDownloadQueue(track.isrc, track.name, track.artists, track.album_name || "");
|
const displayArtist = settings.useFirstArtistOnly && track.artists ? getFirstArtist(track.artists) : track.artists;
|
||||||
|
const itemID = await AddToDownloadQueue(track.spotify_id || "", track.name || "", displayArtist || "", track.album_name || "");
|
||||||
itemIDs.push(itemID);
|
itemIDs.push(itemID);
|
||||||
const trackID = track.spotify_id || track.isrc;
|
const trackID = track.spotify_id || "";
|
||||||
if (existingSpotifyIDs.has(trackID)) {
|
if (existingSpotifyIDs.has(trackID)) {
|
||||||
const filePath = existingFilePaths.get(trackID) || "";
|
const filePath = existingFilePaths.get(trackID) || "";
|
||||||
setTimeout(() => SkipDownloadItem(itemID, filePath), 10);
|
setTimeout(() => SkipDownloadItem(itemID, filePath), 10);
|
||||||
setSkippedTracks((prev) => new Set(prev).add(track.isrc));
|
setSkippedTracks((prev: Set<string>) => new Set(prev).add(trackID));
|
||||||
setDownloadedTracks((prev) => new Set(prev).add(track.isrc));
|
setDownloadedTracks((prev: Set<string>) => new Set(prev).add(trackID));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const tracksToDownload = tracksWithIsrc.filter((track) => {
|
const tracksToDownload = tracksWithId.filter((track) => {
|
||||||
const trackID = track.spotify_id || track.isrc;
|
const trackID = track.spotify_id || "";
|
||||||
return !existingSpotifyIDs.has(trackID);
|
return !existingSpotifyIDs.has(trackID);
|
||||||
});
|
});
|
||||||
let successCount = 0;
|
let successCount = 0;
|
||||||
let errorCount = 0;
|
let errorCount = 0;
|
||||||
let skippedCount = existingSpotifyIDs.size;
|
let skippedCount = existingSpotifyIDs.size;
|
||||||
const total = tracksWithIsrc.length;
|
const total = tracksWithId.length;
|
||||||
setDownloadProgress(Math.round((skippedCount / total) * 100));
|
setDownloadProgress(Math.round((skippedCount / total) * 100));
|
||||||
for (let i = 0; i < tracksToDownload.length; i++) {
|
for (let i = 0; i < tracksToDownload.length; i++) {
|
||||||
if (shouldStopDownloadRef.current) {
|
if (shouldStopDownloadRef.current) {
|
||||||
@@ -875,27 +931,29 @@ export function useDownload(region: string) {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
const track = tracksToDownload[i];
|
const track = tracksToDownload[i];
|
||||||
const originalIndex = tracksWithIsrc.findIndex((t) => t.isrc === track.isrc);
|
const originalIndex = tracksWithId.findIndex((t) => t.spotify_id === track.spotify_id);
|
||||||
const itemID = itemIDs[originalIndex];
|
const itemID = itemIDs[originalIndex];
|
||||||
setDownloadingTrack(track.isrc);
|
const trackId = track.spotify_id || "";
|
||||||
setCurrentDownloadInfo({ name: track.name, artists: track.artists });
|
setDownloadingTrack(trackId);
|
||||||
|
const displayArtist = settings.useFirstArtistOnly && track.artists ? getFirstArtist(track.artists) : track.artists;
|
||||||
|
setCurrentDownloadInfo({ name: track.name || "", artists: displayArtist || "" });
|
||||||
try {
|
try {
|
||||||
const releaseYear = track.release_date?.substring(0, 4);
|
const releaseYear = track.release_date?.substring(0, 4);
|
||||||
const response = await downloadWithItemID(track.isrc, settings, itemID, track.name, track.artists, track.album_name, folderName, originalIndex + 1, track.spotify_id, track.duration_ms, isAlbum, releaseYear, track.album_artist || "", track.release_date, track.images, track.track_number, track.disc_number, track.total_tracks, track.total_discs, track.copyright, track.publisher);
|
const response = await downloadWithItemID(settings, itemID, track.name, track.artists, track.album_name, folderName, originalIndex + 1, track.spotify_id, track.duration_ms, isAlbum, releaseYear, track.album_artist || "", track.release_date, track.images, track.track_number, track.disc_number, track.total_tracks, track.total_discs, track.copyright, track.publisher);
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
if (response.already_exists) {
|
if (response.already_exists) {
|
||||||
skippedCount++;
|
skippedCount++;
|
||||||
logger.info(`skipped: ${track.name} - ${track.artists} (already exists)`);
|
logger.info(`skipped: ${track.name} - ${displayArtist} (already exists)`);
|
||||||
setSkippedTracks((prev) => new Set(prev).add(track.isrc));
|
setSkippedTracks((prev) => new Set(prev).add(trackId));
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
successCount++;
|
successCount++;
|
||||||
logger.success(`downloaded: ${track.name} - ${track.artists}`);
|
logger.success(`downloaded: ${track.name} - ${displayArtist}`);
|
||||||
}
|
}
|
||||||
setDownloadedTracks((prev) => new Set(prev).add(track.isrc));
|
setDownloadedTracks((prev) => new Set(prev).add(trackId));
|
||||||
setFailedTracks((prev) => {
|
setFailedTracks((prev) => {
|
||||||
const newSet = new Set(prev);
|
const newSet = new Set(prev);
|
||||||
newSet.delete(track.isrc);
|
newSet.delete(trackId);
|
||||||
return newSet;
|
return newSet;
|
||||||
});
|
});
|
||||||
if (response.file) {
|
if (response.file) {
|
||||||
@@ -904,14 +962,14 @@ export function useDownload(region: string) {
|
|||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
errorCount++;
|
errorCount++;
|
||||||
logger.error(`failed: ${track.name} - ${track.artists}`);
|
logger.error(`failed: ${track.name} - ${displayArtist}`);
|
||||||
setFailedTracks((prev) => new Set(prev).add(track.isrc));
|
setFailedTracks((prev) => new Set(prev).add(trackId));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
errorCount++;
|
errorCount++;
|
||||||
logger.error(`error: ${track.name} - ${err}`);
|
logger.error(`error: ${track.name} - ${err}`);
|
||||||
setFailedTracks((prev) => new Set(prev).add(track.isrc));
|
setFailedTracks((prev) => new Set(prev).add(trackId));
|
||||||
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App");
|
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App");
|
||||||
await MarkDownloadItemFailed(itemID, err instanceof Error ? err.message : String(err));
|
await MarkDownloadItemFailed(itemID, err instanceof Error ? err.message : String(err));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useState, useRef } from "react";
|
|||||||
import { downloadLyrics } from "@/lib/api";
|
import { downloadLyrics } from "@/lib/api";
|
||||||
import { getSettings, parseTemplate, type TemplateData } from "@/lib/settings";
|
import { getSettings, parseTemplate, type TemplateData } 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, getFirstArtist } from "@/lib/utils";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
import type { TrackMetadata } from "@/types/api";
|
import type { TrackMetadata } from "@/types/api";
|
||||||
export function useLyrics() {
|
export function useLyrics() {
|
||||||
@@ -26,17 +26,21 @@ export function useLyrics() {
|
|||||||
let outputDir = settings.downloadPath;
|
let outputDir = settings.downloadPath;
|
||||||
const placeholder = "__SLASH_PLACEHOLDER__";
|
const placeholder = "__SLASH_PLACEHOLDER__";
|
||||||
const yearValue = releaseDate?.substring(0, 4);
|
const yearValue = releaseDate?.substring(0, 4);
|
||||||
|
const displayArtist = settings.useFirstArtistOnly && artistName ? getFirstArtist(artistName) : artistName;
|
||||||
|
const displayAlbumArtist = settings.useFirstArtistOnly && albumArtist ? getFirstArtist(albumArtist) : albumArtist;
|
||||||
const templateData: TemplateData = {
|
const templateData: TemplateData = {
|
||||||
artist: artistName?.replace(/\//g, placeholder),
|
artist: displayArtist?.replace(/\//g, placeholder),
|
||||||
album: albumName?.replace(/\//g, placeholder),
|
album: albumName?.replace(/\//g, placeholder),
|
||||||
|
album_artist: displayAlbumArtist?.replace(/\//g, placeholder) || displayArtist?.replace(/\//g, placeholder),
|
||||||
title: trackName?.replace(/\//g, placeholder),
|
title: trackName?.replace(/\//g, placeholder),
|
||||||
track: position,
|
track: position,
|
||||||
year: yearValue,
|
year: yearValue,
|
||||||
|
date: releaseDate,
|
||||||
playlist: playlistName?.replace(/\//g, placeholder),
|
playlist: playlistName?.replace(/\//g, placeholder),
|
||||||
};
|
};
|
||||||
const folderTemplate = settings.folderTemplate || "";
|
const folderTemplate = settings.folderTemplate || "";
|
||||||
const useAlbumSubfolder = folderTemplate.includes("{album}") || folderTemplate.includes("{album_artist}") || folderTemplate.includes("{playlist}");
|
const useAlbumSubfolder = folderTemplate.includes("{album}") || folderTemplate.includes("{album_artist}") || folderTemplate.includes("{playlist}");
|
||||||
if (playlistName && (!isAlbum || !useAlbumSubfolder)) {
|
if (settings.createPlaylistFolder && playlistName && (!isAlbum || !useAlbumSubfolder)) {
|
||||||
outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os));
|
outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os));
|
||||||
}
|
}
|
||||||
if (settings.folderTemplate) {
|
if (settings.folderTemplate) {
|
||||||
@@ -53,9 +57,9 @@ export function useLyrics() {
|
|||||||
const response = await downloadLyrics({
|
const response = await downloadLyrics({
|
||||||
spotify_id: spotifyId,
|
spotify_id: spotifyId,
|
||||||
track_name: trackName,
|
track_name: trackName,
|
||||||
artist_name: artistName,
|
artist_name: displayArtist,
|
||||||
album_name: albumName,
|
album_name: albumName,
|
||||||
album_artist: albumArtist,
|
album_artist: displayAlbumArtist,
|
||||||
release_date: releaseDate,
|
release_date: releaseDate,
|
||||||
output_dir: outputDir,
|
output_dir: outputDir,
|
||||||
filename_format: settings.filenameTemplate || "{title}",
|
filename_format: settings.filenameTemplate || "{title}",
|
||||||
@@ -123,17 +127,21 @@ export function useLyrics() {
|
|||||||
const useAlbumTrackNumber = settings.folderTemplate?.includes("{album}") || false;
|
const useAlbumTrackNumber = settings.folderTemplate?.includes("{album}") || false;
|
||||||
const trackPosition = useAlbumTrackNumber ? (track.track_number || i + 1) : (i + 1);
|
const trackPosition = useAlbumTrackNumber ? (track.track_number || i + 1) : (i + 1);
|
||||||
const yearValue = track.release_date?.substring(0, 4);
|
const yearValue = track.release_date?.substring(0, 4);
|
||||||
|
const displayArtist = settings.useFirstArtistOnly && track.artists ? getFirstArtist(track.artists) : track.artists;
|
||||||
|
const displayAlbumArtist = settings.useFirstArtistOnly && track.album_artist ? getFirstArtist(track.album_artist) : track.album_artist;
|
||||||
const templateData: TemplateData = {
|
const templateData: TemplateData = {
|
||||||
artist: track.artists?.replace(/\//g, placeholder),
|
artist: displayArtist?.replace(/\//g, placeholder),
|
||||||
album: track.album_name?.replace(/\//g, placeholder),
|
album: track.album_name?.replace(/\//g, placeholder),
|
||||||
|
album_artist: displayAlbumArtist?.replace(/\//g, placeholder) || displayArtist?.replace(/\//g, placeholder),
|
||||||
title: track.name?.replace(/\//g, placeholder),
|
title: track.name?.replace(/\//g, placeholder),
|
||||||
track: trackPosition,
|
track: trackPosition,
|
||||||
year: yearValue,
|
year: yearValue,
|
||||||
|
date: track.release_date,
|
||||||
playlist: playlistName?.replace(/\//g, placeholder),
|
playlist: playlistName?.replace(/\//g, placeholder),
|
||||||
};
|
};
|
||||||
const folderTemplate = settings.folderTemplate || "";
|
const folderTemplate = settings.folderTemplate || "";
|
||||||
const useAlbumSubfolder = folderTemplate.includes("{album}") || folderTemplate.includes("{album_artist}") || folderTemplate.includes("{playlist}");
|
const useAlbumSubfolder = folderTemplate.includes("{album}") || folderTemplate.includes("{album_artist}") || folderTemplate.includes("{playlist}");
|
||||||
if (playlistName && (!isAlbum || !useAlbumSubfolder)) {
|
if (settings.createPlaylistFolder && playlistName && (!isAlbum || !useAlbumSubfolder)) {
|
||||||
outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os));
|
outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os));
|
||||||
}
|
}
|
||||||
if (settings.folderTemplate) {
|
if (settings.folderTemplate) {
|
||||||
@@ -149,9 +157,9 @@ export function useLyrics() {
|
|||||||
const response = await downloadLyrics({
|
const response = await downloadLyrics({
|
||||||
spotify_id: id,
|
spotify_id: id,
|
||||||
track_name: track.name,
|
track_name: track.name,
|
||||||
artist_name: track.artists,
|
artist_name: displayArtist,
|
||||||
album_name: track.album_name,
|
album_name: track.album_name,
|
||||||
album_artist: track.album_artist,
|
album_artist: displayAlbumArtist,
|
||||||
release_date: track.release_date,
|
release_date: track.release_date,
|
||||||
output_dir: outputDir,
|
output_dir: outputDir,
|
||||||
filename_format: settings.filenameTemplate || "{title}",
|
filename_format: settings.filenameTemplate || "{title}",
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
import { useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { getSettings } from "@/lib/settings";
|
||||||
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 { logger } from "@/lib/logger";
|
||||||
import { AddFetchHistory } from "../../wailsjs/go/main/App";
|
import { AddFetchHistory } from "../../wailsjs/go/main/App";
|
||||||
|
import { EventsOff, EventsOn } from "../../wailsjs/runtime/runtime";
|
||||||
import type { SpotifyMetadataResponse } from "@/types/api";
|
import type { SpotifyMetadataResponse } from "@/types/api";
|
||||||
export function useMetadata() {
|
export function useMetadata() {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [metadata, setMetadata] = useState<SpotifyMetadataResponse | null>(null);
|
const [metadata, setMetadata] = useState<SpotifyMetadataResponse | null>(null);
|
||||||
|
const loadingToastId = useRef<string | number | null>(null);
|
||||||
|
const fetchedCount = useRef(0);
|
||||||
|
const currentName = useRef("");
|
||||||
|
const [showApiModal, setShowApiModal] = useState(false);
|
||||||
const [showAlbumDialog, setShowAlbumDialog] = useState(false);
|
const [showAlbumDialog, setShowAlbumDialog] = useState(false);
|
||||||
const [selectedAlbum, setSelectedAlbum] = useState<{
|
const [selectedAlbum, setSelectedAlbum] = useState<{
|
||||||
id: string;
|
id: string;
|
||||||
@@ -14,6 +20,73 @@ export function useMetadata() {
|
|||||||
external_urls: string;
|
external_urls: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [pendingArtistName, setPendingArtistName] = useState<string | null>(null);
|
const [pendingArtistName, setPendingArtistName] = useState<string | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (loading) {
|
||||||
|
fetchedCount.current = 0;
|
||||||
|
currentName.current = "";
|
||||||
|
loadingToastId.current = toast.silentInfo("fetching metadata...", {
|
||||||
|
duration: Infinity,
|
||||||
|
description: "please wait while we retrieve the information"
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (loadingToastId.current) {
|
||||||
|
toast.dismiss(loadingToastId.current);
|
||||||
|
loadingToastId.current = null;
|
||||||
|
}
|
||||||
|
}, [loading]);
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (data: any) => {
|
||||||
|
if (!data) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
fetchedCount.current += data.length;
|
||||||
|
if (loadingToastId.current && currentName.current) {
|
||||||
|
toast.silentInfo(`fetching tracks for ${currentName.current.toLowerCase()}...`, {
|
||||||
|
id: loadingToastId.current,
|
||||||
|
description: `${fetchedCount.current.toLocaleString()} tracks fetched`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
const baseInfo = data;
|
||||||
|
const name = "artist_info" in baseInfo ? baseInfo.artist_info.name :
|
||||||
|
"album_info" in baseInfo ? baseInfo.album_info.name :
|
||||||
|
"playlist_info" in baseInfo ? (baseInfo.playlist_info.name || baseInfo.playlist_info.owner.name) : "";
|
||||||
|
if (name) {
|
||||||
|
currentName.current = name;
|
||||||
|
if (loadingToastId.current) {
|
||||||
|
toast.silentInfo(`fetching tracks for ${name.toLowerCase()}...`, {
|
||||||
|
id: loadingToastId.current,
|
||||||
|
description: `${fetchedCount.current.toLocaleString()} tracks fetched`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setMetadata(prev => {
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
if (!prev || !("track_list" in prev)) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
track_list: [...prev.track_list, ...data]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (prev && "track_list" in prev && prev.track_list.length > 0) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
const baseInfo = data;
|
||||||
|
if (!("track_list" in baseInfo)) {
|
||||||
|
baseInfo.track_list = [];
|
||||||
|
}
|
||||||
|
return baseInfo;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
EventsOn("metadata-stream", handler);
|
||||||
|
return () => EventsOff("metadata-stream");
|
||||||
|
}, []);
|
||||||
const getUrlType = (url: string): string => {
|
const getUrlType = (url: string): string => {
|
||||||
if (url.includes("/track/"))
|
if (url.includes("/track/"))
|
||||||
return "track";
|
return "track";
|
||||||
@@ -109,7 +182,7 @@ export function useMetadata() {
|
|||||||
saveToHistory(url, data);
|
saveToHistory(url, data);
|
||||||
if ("track" in data) {
|
if ("track" in data) {
|
||||||
logger.success(`fetched track: ${data.track.name} - ${data.track.artists}`);
|
logger.success(`fetched track: ${data.track.name} - ${data.track.artists}`);
|
||||||
logger.debug(`isrc: ${data.track.isrc}, duration: ${data.track.duration_ms}ms`);
|
logger.debug(`duration: ${data.track.duration_ms}ms`);
|
||||||
}
|
}
|
||||||
else if ("album_info" in data) {
|
else if ("album_info" in data) {
|
||||||
logger.success(`fetched album: ${data.album_info.name}`);
|
logger.success(`fetched album: ${data.album_info.name}`);
|
||||||
@@ -129,7 +202,13 @@ export function useMetadata() {
|
|||||||
catch (err) {
|
catch (err) {
|
||||||
const errorMsg = 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}`);
|
logger.error(`fetch failed: ${errorMsg}`);
|
||||||
toast.error(errorMsg);
|
const settings = getSettings();
|
||||||
|
if (!settings.useSpotFetchAPI) {
|
||||||
|
setShowApiModal(true);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
toast.error(errorMsg);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -224,7 +303,13 @@ export function useMetadata() {
|
|||||||
catch (err) {
|
catch (err) {
|
||||||
const errorMsg = 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}`);
|
logger.error(`fetch failed: ${errorMsg}`);
|
||||||
toast.error(errorMsg);
|
const settings = getSettings();
|
||||||
|
if (!settings.useSpotFetchAPI) {
|
||||||
|
setShowApiModal(true);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
toast.error(errorMsg);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -243,6 +328,8 @@ export function useMetadata() {
|
|||||||
handleConfirmAlbumFetch,
|
handleConfirmAlbumFetch,
|
||||||
handleArtistClick,
|
handleArtistClick,
|
||||||
loadFromCache,
|
loadFromCache,
|
||||||
|
showApiModal,
|
||||||
|
setShowApiModal,
|
||||||
resetMetadata: () => setMetadata(null),
|
resetMetadata: () => setMetadata(null),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { GetPreviewURL } from "@/../wailsjs/go/main/App";
|
import { GetPreviewURL } from "@/../wailsjs/go/main/App";
|
||||||
|
import { SPOTIFY_PREVIEW_VOLUME } from "@/lib/preview";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
export function usePreview() {
|
export function usePreview() {
|
||||||
const [loadingPreview, setLoadingPreview] = useState<string | null>(null);
|
const [loadingPreview, setLoadingPreview] = useState<string | null>(null);
|
||||||
@@ -38,6 +39,7 @@ export function usePreview() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const audio = new Audio(previewURL);
|
const audio = new Audio(previewURL);
|
||||||
|
audio.volume = SPOTIFY_PREVIEW_VOLUME;
|
||||||
audio.addEventListener("loadeddata", () => {
|
audio.addEventListener("loadeddata", () => {
|
||||||
setLoadingPreview(null);
|
setLoadingPreview(null);
|
||||||
setPlayingTrack(trackId);
|
setPlayingTrack(trackId);
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ export async function fetchSpotifyMetadata(url: string, batch: boolean = true, d
|
|||||||
}
|
}
|
||||||
export async function downloadTrack(request: DownloadRequest): Promise<DownloadResponse> {
|
export async function downloadTrack(request: DownloadRequest): Promise<DownloadResponse> {
|
||||||
const req = new main.DownloadRequest(request);
|
const req = new main.DownloadRequest(request);
|
||||||
|
if (request.use_single_genre !== undefined) {
|
||||||
|
(req as any).use_single_genre = request.use_single_genre;
|
||||||
|
}
|
||||||
return await DownloadTrack(req);
|
return await DownloadTrack(req);
|
||||||
}
|
}
|
||||||
export async function checkHealth(): Promise<HealthResponse> {
|
export async function checkHealth(): Promise<HealthResponse> {
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
export type AnalyzerColorScheme = "spek" | "viridis" | "hot" | "cool" | "grayscale";
|
||||||
|
export type AnalyzerFreqScale = "linear" | "log2";
|
||||||
|
export type AnalyzerWindowFunction = "hann" | "hamming" | "blackman" | "rectangular";
|
||||||
|
export interface AudioAnalysisPreferences {
|
||||||
|
colorScheme: AnalyzerColorScheme;
|
||||||
|
freqScale: AnalyzerFreqScale;
|
||||||
|
fftSize: number;
|
||||||
|
windowFunction: AnalyzerWindowFunction;
|
||||||
|
}
|
||||||
|
const STORAGE_KEY = "spotiflac_audio_analysis_preferences";
|
||||||
|
const DEFAULT_PREFERENCES: AudioAnalysisPreferences = {
|
||||||
|
colorScheme: "spek",
|
||||||
|
freqScale: "linear",
|
||||||
|
fftSize: 4096,
|
||||||
|
windowFunction: "hann",
|
||||||
|
};
|
||||||
|
const FFT_SIZE_SET = new Set([512, 1024, 2048, 4096]);
|
||||||
|
function toColorScheme(value: unknown): AnalyzerColorScheme {
|
||||||
|
return value === "viridis" || value === "hot" || value === "cool" || value === "grayscale"
|
||||||
|
? value
|
||||||
|
: "spek";
|
||||||
|
}
|
||||||
|
function toFreqScale(value: unknown): AnalyzerFreqScale {
|
||||||
|
return value === "log2" ? "log2" : "linear";
|
||||||
|
}
|
||||||
|
function toFFTSize(value: unknown): number {
|
||||||
|
const num = typeof value === "number" ? value : Number(value);
|
||||||
|
return FFT_SIZE_SET.has(num) ? num : 4096;
|
||||||
|
}
|
||||||
|
function toWindowFunction(value: unknown): AnalyzerWindowFunction {
|
||||||
|
return value === "hamming" || value === "blackman" || value === "rectangular"
|
||||||
|
? value
|
||||||
|
: "hann";
|
||||||
|
}
|
||||||
|
export function loadAudioAnalysisPreferences(): AudioAnalysisPreferences {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!raw)
|
||||||
|
return DEFAULT_PREFERENCES;
|
||||||
|
const parsed = JSON.parse(raw) as Partial<AudioAnalysisPreferences>;
|
||||||
|
return {
|
||||||
|
colorScheme: toColorScheme(parsed.colorScheme),
|
||||||
|
freqScale: toFreqScale(parsed.freqScale),
|
||||||
|
fftSize: toFFTSize(parsed.fftSize),
|
||||||
|
windowFunction: toWindowFunction(parsed.windowFunction),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return DEFAULT_PREFERENCES;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export function saveAudioAnalysisPreferences(preferences: AudioAnalysisPreferences): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify({
|
||||||
|
colorScheme: toColorScheme(preferences.colorScheme),
|
||||||
|
freqScale: toFreqScale(preferences.freqScale),
|
||||||
|
fftSize: toFFTSize(preferences.fftSize),
|
||||||
|
windowFunction: toWindowFunction(preferences.windowFunction),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,727 @@
|
|||||||
|
import type { AnalysisResult, SpectrumData, TimeSlice } from "@/types/api";
|
||||||
|
export interface SpectrumParams {
|
||||||
|
fftSize: number;
|
||||||
|
windowFunction: "hann" | "hamming" | "blackman" | "rectangular";
|
||||||
|
}
|
||||||
|
const DEFAULT_PARAMS: SpectrumParams = {
|
||||||
|
fftSize: 4096,
|
||||||
|
windowFunction: "hann",
|
||||||
|
};
|
||||||
|
const MAX_SPECTRUM_FRAMES = 2200;
|
||||||
|
const METRICS_CHUNK_SIZE = 262144;
|
||||||
|
const AAC_SAMPLE_RATES = [
|
||||||
|
96000, 88200, 64000, 48000, 44100, 32000, 24000,
|
||||||
|
22050, 16000, 12000, 11025, 8000, 7350,
|
||||||
|
] as const;
|
||||||
|
const MP4_CONTAINER_TYPES = new Set([
|
||||||
|
"moov", "trak", "mdia", "minf", "stbl", "edts", "dinf",
|
||||||
|
"udta", "ilst", "meta", "stsd", "wave",
|
||||||
|
]);
|
||||||
|
type SupportedAudioFileType = "FLAC" | "MP3" | "M4A" | "AAC";
|
||||||
|
interface ParsedAudioMetadata {
|
||||||
|
fileType: SupportedAudioFileType;
|
||||||
|
sampleRate: number;
|
||||||
|
channels: number;
|
||||||
|
bitsPerSample: number;
|
||||||
|
totalSamples: number;
|
||||||
|
duration: number;
|
||||||
|
codecMode?: string;
|
||||||
|
bitrateKbps?: number;
|
||||||
|
totalFrames?: number;
|
||||||
|
codecVersion?: string;
|
||||||
|
}
|
||||||
|
interface Mp4BoxInfo {
|
||||||
|
offset: number;
|
||||||
|
size: number;
|
||||||
|
headerSize: number;
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
export interface FrontendAnalysisPayload {
|
||||||
|
result: AnalysisResult;
|
||||||
|
samples: Float32Array;
|
||||||
|
}
|
||||||
|
export interface AudioArrayBufferInput {
|
||||||
|
fileName: string;
|
||||||
|
fileSize: number;
|
||||||
|
arrayBuffer: ArrayBuffer;
|
||||||
|
}
|
||||||
|
export type AnalysisPhase = "read" | "parse" | "decode" | "metrics" | "spectrum" | "finalize";
|
||||||
|
export interface AnalysisProgress {
|
||||||
|
phase: AnalysisPhase;
|
||||||
|
percent: number;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
export type AnalysisProgressCallback = (progress: AnalysisProgress) => void;
|
||||||
|
export type AnalysisCancelCheck = () => boolean;
|
||||||
|
function reportProgress(callback: AnalysisProgressCallback | undefined, phase: AnalysisPhase, percent: number, message: string): void {
|
||||||
|
if (!callback)
|
||||||
|
return;
|
||||||
|
callback({
|
||||||
|
phase,
|
||||||
|
percent: Math.max(0, Math.min(100, percent)),
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function throwIfCancelled(cancelCheck?: AnalysisCancelCheck): void {
|
||||||
|
if (cancelCheck?.()) {
|
||||||
|
throw new Error("Analysis cancelled");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function nowMs(): number {
|
||||||
|
return typeof performance !== "undefined" ? performance.now() : Date.now();
|
||||||
|
}
|
||||||
|
function nextTick(): Promise<void> {
|
||||||
|
if (typeof requestAnimationFrame === "function") {
|
||||||
|
return new Promise((resolve) => requestAnimationFrame(() => resolve()));
|
||||||
|
}
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
}
|
||||||
|
function readFourCC(view: DataView, offset: number): string {
|
||||||
|
return String.fromCharCode(view.getUint8(offset), view.getUint8(offset + 1), view.getUint8(offset + 2), view.getUint8(offset + 3));
|
||||||
|
}
|
||||||
|
function fileExtension(fileName: string): string {
|
||||||
|
const normalized = fileName.toLowerCase();
|
||||||
|
const dotIndex = normalized.lastIndexOf(".");
|
||||||
|
return dotIndex >= 0 ? normalized.slice(dotIndex) : "";
|
||||||
|
}
|
||||||
|
function detectAudioFileType(buffer: ArrayBuffer, fileName = ""): SupportedAudioFileType {
|
||||||
|
const view = new DataView(buffer);
|
||||||
|
if (view.byteLength >= 4 && view.getUint32(0, false) === 0x664c6143) {
|
||||||
|
return "FLAC";
|
||||||
|
}
|
||||||
|
if (view.byteLength >= 3 &&
|
||||||
|
view.getUint8(0) === 0x49 &&
|
||||||
|
view.getUint8(1) === 0x44 &&
|
||||||
|
view.getUint8(2) === 0x33) {
|
||||||
|
return "MP3";
|
||||||
|
}
|
||||||
|
if (view.byteLength >= 8 && readFourCC(view, 4) === "ftyp") {
|
||||||
|
return "M4A";
|
||||||
|
}
|
||||||
|
if (view.byteLength >= 2 && view.getUint8(0) === 0xff && (view.getUint8(1) & 0xf6) === 0xf0) {
|
||||||
|
return "AAC";
|
||||||
|
}
|
||||||
|
for (let offset = 0; offset < Math.min(4096, view.byteLength - 4); offset++) {
|
||||||
|
const header = view.getUint32(offset, false);
|
||||||
|
if ((header >>> 21) === 0x7ff) {
|
||||||
|
const version = (header >>> 19) & 0x03;
|
||||||
|
const layer = (header >>> 17) & 0x03;
|
||||||
|
const sampleRateIndex = (header >>> 10) & 0x03;
|
||||||
|
if (version !== 1 && layer !== 0 && sampleRateIndex !== 3) {
|
||||||
|
return "MP3";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch (fileExtension(fileName)) {
|
||||||
|
case ".flac": return "FLAC";
|
||||||
|
case ".mp3": return "MP3";
|
||||||
|
case ".m4a":
|
||||||
|
case ".mp4": return "M4A";
|
||||||
|
case ".aac": return "AAC";
|
||||||
|
default: throw new Error(`Unsupported audio format: ${fileName || "unknown"}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function parseFlacMetadata(buffer: ArrayBuffer): ParsedAudioMetadata {
|
||||||
|
const data = new Uint8Array(buffer);
|
||||||
|
if (data.length < 4 || data[0] !== 0x66 || data[1] !== 0x4c || data[2] !== 0x61 || data[3] !== 0x43) {
|
||||||
|
throw new Error("Invalid FLAC file");
|
||||||
|
}
|
||||||
|
let offset = 4;
|
||||||
|
while (offset + 4 <= data.length) {
|
||||||
|
const blockHeader = data[offset];
|
||||||
|
const blockType = blockHeader & 0x7f;
|
||||||
|
const blockLength = (data[offset + 1] << 16) | (data[offset + 2] << 8) | data[offset + 3];
|
||||||
|
offset += 4;
|
||||||
|
if (offset + blockLength > data.length)
|
||||||
|
break;
|
||||||
|
if (blockType === 0 && blockLength >= 18) {
|
||||||
|
const streamInfo = data.subarray(offset, offset + blockLength);
|
||||||
|
const sampleRate = (streamInfo[10] << 12) | (streamInfo[11] << 4) | (streamInfo[12] >> 4);
|
||||||
|
const channels = ((streamInfo[12] >> 1) & 0x07) + 1;
|
||||||
|
const bitsPerSample = (((streamInfo[12] & 0x01) << 4) | (streamInfo[13] >> 4)) + 1;
|
||||||
|
const totalSamplesBig = (BigInt(streamInfo[13] & 0x0f) << 32n) |
|
||||||
|
(BigInt(streamInfo[14]) << 24n) |
|
||||||
|
(BigInt(streamInfo[15]) << 16n) |
|
||||||
|
(BigInt(streamInfo[16]) << 8n) |
|
||||||
|
BigInt(streamInfo[17]);
|
||||||
|
const totalSamples = Number(totalSamplesBig);
|
||||||
|
const duration = sampleRate > 0 && totalSamples > 0 ? totalSamples / sampleRate : 0;
|
||||||
|
return {
|
||||||
|
fileType: "FLAC",
|
||||||
|
sampleRate,
|
||||||
|
channels,
|
||||||
|
bitsPerSample,
|
||||||
|
totalSamples,
|
||||||
|
duration,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
offset += blockLength;
|
||||||
|
}
|
||||||
|
throw new Error("FLAC STREAMINFO metadata not found");
|
||||||
|
}
|
||||||
|
function skipId3v2Tag(view: DataView): number {
|
||||||
|
if (view.byteLength < 10 ||
|
||||||
|
view.getUint8(0) !== 0x49 ||
|
||||||
|
view.getUint8(1) !== 0x44 ||
|
||||||
|
view.getUint8(2) !== 0x33) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const size = ((view.getUint8(6) & 0x7f) << 21) |
|
||||||
|
((view.getUint8(7) & 0x7f) << 14) |
|
||||||
|
((view.getUint8(8) & 0x7f) << 7) |
|
||||||
|
(view.getUint8(9) & 0x7f);
|
||||||
|
let offset = 10 + size;
|
||||||
|
if ((view.getUint8(5) & 0x10) !== 0) {
|
||||||
|
offset += 10;
|
||||||
|
}
|
||||||
|
return offset < view.byteLength ? offset : 0;
|
||||||
|
}
|
||||||
|
function getMp3Bitrate(version: number, layer: number, bitrateIndex: number): number {
|
||||||
|
const tables: Record<number, Record<number, number[]>> = {
|
||||||
|
1: {
|
||||||
|
1: [0, 32, 64, 96, 128, 160, 192, 224, 256, 288, 320, 352, 384, 416, 448, 0],
|
||||||
|
2: [0, 32, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 384, 0],
|
||||||
|
3: [0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0],
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
1: [0, 32, 48, 56, 64, 80, 96, 112, 128, 144, 160, 176, 192, 224, 256, 0],
|
||||||
|
2: [0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0],
|
||||||
|
3: [0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const normalizedVersion = version === 2.5 ? 2 : version;
|
||||||
|
return tables[normalizedVersion]?.[layer]?.[bitrateIndex] ?? 0;
|
||||||
|
}
|
||||||
|
function getMp3SamplesPerFrame(version: number, layer: number): number {
|
||||||
|
if (layer === 1)
|
||||||
|
return 384;
|
||||||
|
if (version === 1)
|
||||||
|
return 1152;
|
||||||
|
return 576;
|
||||||
|
}
|
||||||
|
interface Mp3FrameInfo {
|
||||||
|
version: number;
|
||||||
|
versionName: string;
|
||||||
|
layer: number;
|
||||||
|
sampleRate: number;
|
||||||
|
bitrate: number;
|
||||||
|
channels: number;
|
||||||
|
frameSize: number;
|
||||||
|
samplesPerFrame: number;
|
||||||
|
}
|
||||||
|
function parseMp3FrameHeader(header: number): Mp3FrameInfo | null {
|
||||||
|
if (((header >>> 21) & 0x7ff) !== 0x7ff)
|
||||||
|
return null;
|
||||||
|
const versionBits = (header >>> 19) & 0x03;
|
||||||
|
const layerBits = (header >>> 17) & 0x03;
|
||||||
|
const bitrateIndex = (header >>> 12) & 0x0f;
|
||||||
|
const sampleRateIndex = (header >>> 10) & 0x03;
|
||||||
|
const padding = (header >>> 9) & 0x01;
|
||||||
|
const channelMode = (header >>> 6) & 0x03;
|
||||||
|
const versions = [2.5, null, 2, 1] as const;
|
||||||
|
const layers = [null, 3, 2, 1] as const;
|
||||||
|
const version = versions[versionBits];
|
||||||
|
const layer = layers[layerBits];
|
||||||
|
if (version === null || layer === null || sampleRateIndex === 3)
|
||||||
|
return null;
|
||||||
|
const sampleRateTables: Record<1 | 2 | 25, [
|
||||||
|
number,
|
||||||
|
number,
|
||||||
|
number
|
||||||
|
]> = {
|
||||||
|
1: [44100, 48000, 32000],
|
||||||
|
2: [22050, 24000, 16000],
|
||||||
|
25: [11025, 12000, 8000],
|
||||||
|
};
|
||||||
|
const sampleRateKey = version === 2.5 ? 25 : (version as 1 | 2);
|
||||||
|
const sampleRate = sampleRateTables[sampleRateKey][sampleRateIndex];
|
||||||
|
const bitrate = getMp3Bitrate(version, layer, bitrateIndex);
|
||||||
|
const samplesPerFrame = getMp3SamplesPerFrame(version, layer);
|
||||||
|
if (!sampleRate || !bitrate || !samplesPerFrame)
|
||||||
|
return null;
|
||||||
|
return {
|
||||||
|
version,
|
||||||
|
versionName: `MPEG-${version === 1 ? "1" : version === 2 ? "2" : "2.5"}`,
|
||||||
|
layer,
|
||||||
|
sampleRate,
|
||||||
|
bitrate,
|
||||||
|
channels: channelMode === 3 ? 1 : 2,
|
||||||
|
frameSize: Math.floor((samplesPerFrame / 8 * bitrate * 1000) / sampleRate) + padding,
|
||||||
|
samplesPerFrame,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function getMp3SideInfoSize(frameInfo: Mp3FrameInfo): number {
|
||||||
|
if (frameInfo.version === 1) {
|
||||||
|
return frameInfo.channels === 1 ? 17 : 32;
|
||||||
|
}
|
||||||
|
return frameInfo.channels === 1 ? 9 : 17;
|
||||||
|
}
|
||||||
|
function parseMp3XingHeader(view: DataView, offset: number, frameInfo: Mp3FrameInfo) {
|
||||||
|
if (offset + 16 > view.byteLength)
|
||||||
|
return null;
|
||||||
|
const flags = view.getUint32(offset + 4, false);
|
||||||
|
let pos = offset + 8;
|
||||||
|
let totalFrames = 0;
|
||||||
|
let totalBytes = 0;
|
||||||
|
if ((flags & 0x01) !== 0 && pos + 4 <= view.byteLength) {
|
||||||
|
totalFrames = view.getUint32(pos, false);
|
||||||
|
pos += 4;
|
||||||
|
}
|
||||||
|
if ((flags & 0x02) !== 0 && pos + 4 <= view.byteLength) {
|
||||||
|
totalBytes = view.getUint32(pos, false);
|
||||||
|
}
|
||||||
|
const duration = totalFrames > 0 ? (totalFrames * frameInfo.samplesPerFrame) / frameInfo.sampleRate : 0;
|
||||||
|
const avgBitrate = duration > 0 && totalBytes > 0 ? Math.round((totalBytes * 8) / duration / 1000) : frameInfo.bitrate;
|
||||||
|
return {
|
||||||
|
codecMode: "VBR (Xing)",
|
||||||
|
totalFrames,
|
||||||
|
duration,
|
||||||
|
bitrateKbps: avgBitrate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function parseMp3VbriHeader(view: DataView, offset: number, frameInfo: Mp3FrameInfo) {
|
||||||
|
if (offset + 18 > view.byteLength)
|
||||||
|
return null;
|
||||||
|
const totalBytes = view.getUint32(offset + 10, false);
|
||||||
|
const totalFrames = view.getUint32(offset + 14, false);
|
||||||
|
const duration = totalFrames > 0 ? (totalFrames * frameInfo.samplesPerFrame) / frameInfo.sampleRate : 0;
|
||||||
|
const bitrateKbps = duration > 0 && totalBytes > 0 ? Math.round((totalBytes * 8) / duration / 1000) : frameInfo.bitrate;
|
||||||
|
return {
|
||||||
|
codecMode: "VBR (VBRI)",
|
||||||
|
totalFrames,
|
||||||
|
duration,
|
||||||
|
bitrateKbps,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function parseMp3VbrInfo(view: DataView, frameOffset: number, frameInfo: Mp3FrameInfo) {
|
||||||
|
const sideInfoSize = getMp3SideInfoSize(frameInfo);
|
||||||
|
const xingOffset = frameOffset + 4 + sideInfoSize;
|
||||||
|
if (xingOffset + 4 <= view.byteLength) {
|
||||||
|
const xingTag = String.fromCharCode(view.getUint8(xingOffset), view.getUint8(xingOffset + 1), view.getUint8(xingOffset + 2), view.getUint8(xingOffset + 3));
|
||||||
|
if (xingTag === "Xing" || xingTag === "Info") {
|
||||||
|
return parseMp3XingHeader(view, xingOffset, frameInfo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const vbriOffset = frameOffset + 36;
|
||||||
|
if (vbriOffset + 4 <= view.byteLength) {
|
||||||
|
const vbriTag = String.fromCharCode(view.getUint8(vbriOffset), view.getUint8(vbriOffset + 1), view.getUint8(vbriOffset + 2), view.getUint8(vbriOffset + 3));
|
||||||
|
if (vbriTag === "VBRI") {
|
||||||
|
return parseMp3VbriHeader(view, vbriOffset, frameInfo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
function parseMp3Metadata(buffer: ArrayBuffer): ParsedAudioMetadata {
|
||||||
|
const view = new DataView(buffer);
|
||||||
|
const startOffset = skipId3v2Tag(view);
|
||||||
|
for (let offset = startOffset; offset <= view.byteLength - 4; offset++) {
|
||||||
|
const header = view.getUint32(offset, false);
|
||||||
|
const frameInfo = parseMp3FrameHeader(header);
|
||||||
|
if (frameInfo) {
|
||||||
|
const vbrInfo = parseMp3VbrInfo(view, offset, frameInfo);
|
||||||
|
const estimatedAudioDataSize = Math.max(0, view.byteLength - offset);
|
||||||
|
const estimatedFrameSize = frameInfo.frameSize > 0 ? frameInfo.frameSize : 1;
|
||||||
|
const totalFrames = vbrInfo?.totalFrames ?? Math.floor(estimatedAudioDataSize / estimatedFrameSize);
|
||||||
|
const duration = vbrInfo?.duration ?? ((totalFrames * frameInfo.samplesPerFrame) / frameInfo.sampleRate);
|
||||||
|
const bitrateKbps = vbrInfo?.bitrateKbps ?? frameInfo.bitrate;
|
||||||
|
return {
|
||||||
|
fileType: "MP3",
|
||||||
|
sampleRate: frameInfo.sampleRate,
|
||||||
|
channels: frameInfo.channels,
|
||||||
|
bitsPerSample: 16,
|
||||||
|
totalSamples: duration > 0 ? Math.floor(duration * frameInfo.sampleRate) : 0,
|
||||||
|
duration,
|
||||||
|
codecMode: vbrInfo?.codecMode ?? "CBR",
|
||||||
|
bitrateKbps,
|
||||||
|
totalFrames,
|
||||||
|
codecVersion: frameInfo.versionName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error("No valid MP3 frame found");
|
||||||
|
}
|
||||||
|
function parseAacMetadata(buffer: ArrayBuffer): ParsedAudioMetadata {
|
||||||
|
const data = new Uint8Array(buffer);
|
||||||
|
for (let offset = 0; offset <= data.length - 7; offset++) {
|
||||||
|
if (data[offset] !== 0xff || (data[offset + 1] & 0xf6) !== 0xf0)
|
||||||
|
continue;
|
||||||
|
const sampleRateIndex = (data[offset + 2] >> 2) & 0x0f;
|
||||||
|
const sampleRate = AAC_SAMPLE_RATES[sampleRateIndex];
|
||||||
|
const channels = ((data[offset + 2] & 0x01) << 2) | ((data[offset + 3] >> 6) & 0x03);
|
||||||
|
if (!sampleRate)
|
||||||
|
continue;
|
||||||
|
return {
|
||||||
|
fileType: "AAC",
|
||||||
|
sampleRate,
|
||||||
|
channels: channels || 2,
|
||||||
|
bitsPerSample: 16,
|
||||||
|
totalSamples: 0,
|
||||||
|
duration: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw new Error("No valid AAC ADTS header found");
|
||||||
|
}
|
||||||
|
function readMp4Box(view: DataView, offset: number, limit: number): Mp4BoxInfo | null {
|
||||||
|
if (offset + 8 > limit)
|
||||||
|
return null;
|
||||||
|
let size = view.getUint32(offset, false);
|
||||||
|
const type = readFourCC(view, offset + 4);
|
||||||
|
let headerSize = 8;
|
||||||
|
if (size === 1) {
|
||||||
|
if (offset + 16 > limit)
|
||||||
|
return null;
|
||||||
|
const high = view.getUint32(offset + 8, false);
|
||||||
|
const low = view.getUint32(offset + 12, false);
|
||||||
|
size = high * 4294967296 + low;
|
||||||
|
headerSize = 16;
|
||||||
|
}
|
||||||
|
else if (size === 0) {
|
||||||
|
size = limit - offset;
|
||||||
|
}
|
||||||
|
if (size < headerSize || offset + size > limit)
|
||||||
|
return null;
|
||||||
|
return { offset, size, headerSize, type };
|
||||||
|
}
|
||||||
|
function parseM4aMetadata(buffer: ArrayBuffer): ParsedAudioMetadata {
|
||||||
|
const view = new DataView(buffer);
|
||||||
|
let sampleRate = 0;
|
||||||
|
let channels = 0;
|
||||||
|
let bitsPerSample = 0;
|
||||||
|
let duration = 0;
|
||||||
|
const scanBoxes = (start: number, end: number): void => {
|
||||||
|
let offset = start;
|
||||||
|
while (offset + 8 <= end) {
|
||||||
|
const box = readMp4Box(view, offset, end);
|
||||||
|
if (!box)
|
||||||
|
break;
|
||||||
|
const boxEnd = box.offset + box.size;
|
||||||
|
const contentStart = box.offset + box.headerSize;
|
||||||
|
if (box.type === "mdhd" && contentStart + 24 <= boxEnd) {
|
||||||
|
const version = view.getUint8(contentStart);
|
||||||
|
if (version === 0 && contentStart + 24 <= boxEnd) {
|
||||||
|
const timeScale = view.getUint32(contentStart + 12, false);
|
||||||
|
const durationValue = view.getUint32(contentStart + 16, false);
|
||||||
|
if (timeScale > 0) {
|
||||||
|
sampleRate = timeScale;
|
||||||
|
duration = durationValue / timeScale;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (version === 1 && contentStart + 36 <= boxEnd) {
|
||||||
|
const timeScale = view.getUint32(contentStart + 20, false);
|
||||||
|
const durationHigh = view.getUint32(contentStart + 24, false);
|
||||||
|
const durationLow = view.getUint32(contentStart + 28, false);
|
||||||
|
const durationValue = durationHigh * 4294967296 + durationLow;
|
||||||
|
if (timeScale > 0) {
|
||||||
|
sampleRate = timeScale;
|
||||||
|
duration = durationValue / timeScale;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if ((box.type === "mp4a" || box.type === "aac ") && box.offset + 36 <= boxEnd) {
|
||||||
|
channels = view.getUint16(box.offset + 24, false) || channels;
|
||||||
|
bitsPerSample = view.getUint16(box.offset + 26, false) || bitsPerSample;
|
||||||
|
if (!sampleRate) {
|
||||||
|
const fixedPointSampleRate = view.getUint32(box.offset + 32, false);
|
||||||
|
if (fixedPointSampleRate > 0) {
|
||||||
|
sampleRate = Math.floor(fixedPointSampleRate / 65536);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (MP4_CONTAINER_TYPES.has(box.type)) {
|
||||||
|
let childStart = contentStart;
|
||||||
|
if (box.type === "meta")
|
||||||
|
childStart = Math.min(boxEnd, contentStart + 4);
|
||||||
|
else if (box.type === "stsd")
|
||||||
|
childStart = Math.min(boxEnd, contentStart + 8);
|
||||||
|
if (childStart < boxEnd)
|
||||||
|
scanBoxes(childStart, boxEnd);
|
||||||
|
}
|
||||||
|
offset = boxEnd;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
scanBoxes(0, view.byteLength);
|
||||||
|
if (sampleRate <= 0)
|
||||||
|
sampleRate = 44100;
|
||||||
|
if (channels <= 0)
|
||||||
|
channels = 2;
|
||||||
|
if (bitsPerSample <= 0)
|
||||||
|
bitsPerSample = 16;
|
||||||
|
return {
|
||||||
|
fileType: "M4A",
|
||||||
|
sampleRate,
|
||||||
|
channels,
|
||||||
|
bitsPerSample,
|
||||||
|
totalSamples: duration > 0 ? Math.floor(duration * sampleRate) : 0,
|
||||||
|
duration,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function parseAudioMetadata(input: AudioArrayBufferInput): ParsedAudioMetadata {
|
||||||
|
const fileType = detectAudioFileType(input.arrayBuffer, input.fileName);
|
||||||
|
switch (fileType) {
|
||||||
|
case "FLAC": return parseFlacMetadata(input.arrayBuffer);
|
||||||
|
case "MP3": return parseMp3Metadata(input.arrayBuffer);
|
||||||
|
case "M4A": return parseM4aMetadata(input.arrayBuffer);
|
||||||
|
case "AAC": return parseAacMetadata(input.arrayBuffer);
|
||||||
|
default: throw new Error(`Unsupported audio format: ${input.fileName || "unknown"}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function buildWindowCoefficients(size: number, windowFunction: SpectrumParams["windowFunction"]): Float32Array {
|
||||||
|
const coeffs = new Float32Array(size);
|
||||||
|
if (size <= 1) {
|
||||||
|
coeffs.fill(1);
|
||||||
|
return coeffs;
|
||||||
|
}
|
||||||
|
for (let i = 0; i < size; i++) {
|
||||||
|
switch (windowFunction) {
|
||||||
|
case "hamming":
|
||||||
|
coeffs[i] = 0.54 - 0.46 * Math.cos((2 * Math.PI * i) / (size - 1));
|
||||||
|
break;
|
||||||
|
case "blackman":
|
||||||
|
coeffs[i] =
|
||||||
|
0.42 -
|
||||||
|
0.5 * Math.cos((2 * Math.PI * i) / (size - 1)) +
|
||||||
|
0.08 * Math.cos((4 * Math.PI * i) / (size - 1));
|
||||||
|
break;
|
||||||
|
case "rectangular":
|
||||||
|
coeffs[i] = 1;
|
||||||
|
break;
|
||||||
|
case "hann":
|
||||||
|
default:
|
||||||
|
coeffs[i] = 0.5 * (1 - Math.cos((2 * Math.PI * i) / (size - 1)));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return coeffs;
|
||||||
|
}
|
||||||
|
function buildBitReversal(size: number): Uint32Array {
|
||||||
|
let bits = 0;
|
||||||
|
while ((1 << bits) < size)
|
||||||
|
bits++;
|
||||||
|
const out = new Uint32Array(size);
|
||||||
|
for (let i = 0; i < size; i++) {
|
||||||
|
let x = i;
|
||||||
|
let rev = 0;
|
||||||
|
for (let b = 0; b < bits; b++) {
|
||||||
|
rev = (rev << 1) | (x & 1);
|
||||||
|
x >>= 1;
|
||||||
|
}
|
||||||
|
out[i] = rev;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
function fftInPlace(real: Float32Array, imag: Float32Array, bitReversal: Uint32Array): void {
|
||||||
|
const size = real.length;
|
||||||
|
for (let i = 1; i < size; i++) {
|
||||||
|
const j = bitReversal[i];
|
||||||
|
if (i < j) {
|
||||||
|
const tr = real[i];
|
||||||
|
real[i] = real[j];
|
||||||
|
real[j] = tr;
|
||||||
|
const ti = imag[i];
|
||||||
|
imag[i] = imag[j];
|
||||||
|
imag[j] = ti;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let len = 2; len <= size; len <<= 1) {
|
||||||
|
const wLen = (-2 * Math.PI) / len;
|
||||||
|
const wLenReal = Math.cos(wLen);
|
||||||
|
const wLenImag = Math.sin(wLen);
|
||||||
|
for (let i = 0; i < size; i += len) {
|
||||||
|
let wReal = 1;
|
||||||
|
let wImag = 0;
|
||||||
|
const half = len >> 1;
|
||||||
|
for (let j = 0; j < half; j++) {
|
||||||
|
const uReal = real[i + j];
|
||||||
|
const uImag = imag[i + j];
|
||||||
|
const vReal = real[i + j + half] * wReal - imag[i + j + half] * wImag;
|
||||||
|
const vImag = real[i + j + half] * wImag + imag[i + j + half] * wReal;
|
||||||
|
real[i + j] = uReal + vReal;
|
||||||
|
imag[i + j] = uImag + vImag;
|
||||||
|
real[i + j + half] = uReal - vReal;
|
||||||
|
imag[i + j + half] = uImag - vImag;
|
||||||
|
const tempReal = wReal * wLenReal - wImag * wLenImag;
|
||||||
|
wImag = wReal * wLenImag + wImag * wLenReal;
|
||||||
|
wReal = tempReal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export async function analyzeSpectrumFromSamples(samples: Float32Array, sampleRate: number, params: SpectrumParams, onProgress?: AnalysisProgressCallback, shouldCancel?: AnalysisCancelCheck): Promise<SpectrumData> {
|
||||||
|
throwIfCancelled(shouldCancel);
|
||||||
|
const fftSize = params.fftSize;
|
||||||
|
const hopSize = Math.max(1, Math.floor(fftSize / 4));
|
||||||
|
const rawWindows = Math.floor((samples.length - fftSize) / hopSize);
|
||||||
|
const numWindows = Math.max(1, rawWindows);
|
||||||
|
const frameStride = Math.max(1, Math.ceil(numWindows / MAX_SPECTRUM_FRAMES));
|
||||||
|
const freqBins = Math.floor(fftSize / 2) + 1;
|
||||||
|
const duration = sampleRate > 0 ? samples.length / sampleRate : 0;
|
||||||
|
const maxFreq = sampleRate / 2;
|
||||||
|
const windowCoeffs = buildWindowCoefficients(fftSize, params.windowFunction);
|
||||||
|
const bitReversal = buildBitReversal(fftSize);
|
||||||
|
const real = new Float32Array(fftSize);
|
||||||
|
const imag = new Float32Array(fftSize);
|
||||||
|
const invFFTSizeSquared = 1 / (fftSize * fftSize);
|
||||||
|
reportProgress(onProgress, "spectrum", 0, "Preparing FFT...");
|
||||||
|
const windowIndices: number[] = [];
|
||||||
|
for (let windowIndex = 0; windowIndex < numWindows; windowIndex += frameStride) {
|
||||||
|
windowIndices.push(windowIndex);
|
||||||
|
}
|
||||||
|
if (windowIndices[windowIndices.length - 1] !== numWindows - 1) {
|
||||||
|
windowIndices.push(numWindows - 1);
|
||||||
|
}
|
||||||
|
const totalSlices = windowIndices.length;
|
||||||
|
const timeSlices: TimeSlice[] = new Array(totalSlices);
|
||||||
|
let lastReportedPercent = -1;
|
||||||
|
let lastYieldAt = nowMs();
|
||||||
|
for (let i = 0; i < totalSlices; i++) {
|
||||||
|
throwIfCancelled(shouldCancel);
|
||||||
|
const windowIndex = windowIndices[i];
|
||||||
|
const start = windowIndex * hopSize;
|
||||||
|
const remaining = samples.length - start;
|
||||||
|
const copyLen = Math.max(0, Math.min(fftSize, remaining));
|
||||||
|
for (let j = 0; j < copyLen; j++) {
|
||||||
|
real[j] = samples[start + j] * windowCoeffs[j];
|
||||||
|
imag[j] = 0;
|
||||||
|
}
|
||||||
|
for (let j = copyLen; j < fftSize; j++) {
|
||||||
|
real[j] = 0;
|
||||||
|
imag[j] = 0;
|
||||||
|
}
|
||||||
|
fftInPlace(real, imag, bitReversal);
|
||||||
|
const magnitudes = new Float32Array(freqBins);
|
||||||
|
for (let j = 0; j < freqBins; j++) {
|
||||||
|
const power = (real[j] * real[j] + imag[j] * imag[j]) * invFFTSizeSquared;
|
||||||
|
magnitudes[j] = power > 1e-12 ? 10 * Math.log10(power) : -120;
|
||||||
|
}
|
||||||
|
timeSlices[i] = {
|
||||||
|
time: sampleRate > 0 ? start / sampleRate : 0,
|
||||||
|
magnitudes,
|
||||||
|
};
|
||||||
|
const currentPercent = Math.floor(((i + 1) / totalSlices) * 100);
|
||||||
|
if (currentPercent > lastReportedPercent) {
|
||||||
|
lastReportedPercent = currentPercent;
|
||||||
|
reportProgress(onProgress, "spectrum", currentPercent, "Analyzing spectrum...");
|
||||||
|
}
|
||||||
|
if ((i + 1) % 8 === 0) {
|
||||||
|
const now = nowMs();
|
||||||
|
if (now - lastYieldAt >= 16) {
|
||||||
|
await nextTick();
|
||||||
|
lastYieldAt = nowMs();
|
||||||
|
throwIfCancelled(shouldCancel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reportProgress(onProgress, "spectrum", 100, "Spectrum analysis complete");
|
||||||
|
return {
|
||||||
|
time_slices: timeSlices,
|
||||||
|
sample_rate: sampleRate,
|
||||||
|
freq_bins: freqBins,
|
||||||
|
duration,
|
||||||
|
max_freq: maxFreq,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function createAnalysisAudioContext(sampleRate: number): AudioContext {
|
||||||
|
if (sampleRate > 0) {
|
||||||
|
try {
|
||||||
|
return new AudioContext({ sampleRate });
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return new AudioContext();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new AudioContext();
|
||||||
|
}
|
||||||
|
export async function analyzeAudioFile(file: File, params: SpectrumParams = DEFAULT_PARAMS, onProgress?: AnalysisProgressCallback, shouldCancel?: AnalysisCancelCheck): Promise<FrontendAnalysisPayload> {
|
||||||
|
throwIfCancelled(shouldCancel);
|
||||||
|
reportProgress(onProgress, "read", 2, "Reading file...");
|
||||||
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
throwIfCancelled(shouldCancel);
|
||||||
|
reportProgress(onProgress, "read", 10, "File loaded");
|
||||||
|
return analyzeAudioArrayBuffer({
|
||||||
|
fileName: file.name,
|
||||||
|
fileSize: file.size,
|
||||||
|
arrayBuffer,
|
||||||
|
}, params, (progress) => {
|
||||||
|
const mappedPercent = 10 + (progress.percent * 0.9);
|
||||||
|
reportProgress(onProgress, progress.phase, mappedPercent, progress.message);
|
||||||
|
}, shouldCancel);
|
||||||
|
}
|
||||||
|
export async function analyzeAudioArrayBuffer(input: AudioArrayBufferInput, params: SpectrumParams = DEFAULT_PARAMS, onProgress?: AnalysisProgressCallback, shouldCancel?: AnalysisCancelCheck): Promise<FrontendAnalysisPayload> {
|
||||||
|
throwIfCancelled(shouldCancel);
|
||||||
|
reportProgress(onProgress, "parse", 5, "Parsing audio metadata...");
|
||||||
|
const metadata = parseAudioMetadata(input);
|
||||||
|
throwIfCancelled(shouldCancel);
|
||||||
|
reportProgress(onProgress, "decode", 15, "Decoding audio stream...");
|
||||||
|
const audioContext = createAnalysisAudioContext(metadata.sampleRate);
|
||||||
|
try {
|
||||||
|
const audioBuffer = await audioContext.decodeAudioData(input.arrayBuffer.slice(0));
|
||||||
|
throwIfCancelled(shouldCancel);
|
||||||
|
reportProgress(onProgress, "decode", 35, "Audio decoded");
|
||||||
|
const samples = audioBuffer.getChannelData(0);
|
||||||
|
reportProgress(onProgress, "metrics", 40, "Calculating peak/RMS...");
|
||||||
|
let peak = 0;
|
||||||
|
let sumSquares = 0;
|
||||||
|
let lastMetricsYieldAt = nowMs();
|
||||||
|
for (let i = 0; i < samples.length; i++) {
|
||||||
|
throwIfCancelled(shouldCancel);
|
||||||
|
const sample = samples[i];
|
||||||
|
const absSample = Math.abs(sample);
|
||||||
|
if (absSample > peak)
|
||||||
|
peak = absSample;
|
||||||
|
sumSquares += sample * sample;
|
||||||
|
if ((i + 1) % METRICS_CHUNK_SIZE === 0 || i === samples.length - 1) {
|
||||||
|
const metricsProgress = 40 + (((i + 1) / samples.length) * 10);
|
||||||
|
reportProgress(onProgress, "metrics", metricsProgress, "Calculating peak/RMS...");
|
||||||
|
const now = nowMs();
|
||||||
|
if (now - lastMetricsYieldAt >= 16) {
|
||||||
|
await nextTick();
|
||||||
|
lastMetricsYieldAt = nowMs();
|
||||||
|
throwIfCancelled(shouldCancel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const peakDB = peak > 0 ? 20 * Math.log10(peak) : -120;
|
||||||
|
const rms = samples.length > 0 ? Math.sqrt(sumSquares / samples.length) : 0;
|
||||||
|
const rmsDB = rms > 0 ? 20 * Math.log10(rms) : -120;
|
||||||
|
const dynamicRange = peakDB - rmsDB;
|
||||||
|
const duration = audioBuffer.duration > 0 ? audioBuffer.duration : metadata.duration;
|
||||||
|
const totalSamples = metadata.totalSamples > 0
|
||||||
|
? metadata.totalSamples
|
||||||
|
: Math.floor(duration * metadata.sampleRate);
|
||||||
|
reportProgress(onProgress, "metrics", 50, "Signal metrics complete");
|
||||||
|
const spectrum = await analyzeSpectrumFromSamples(samples, metadata.sampleRate, params, (progress) => {
|
||||||
|
const mappedPercent = 50 + (progress.percent * 0.45);
|
||||||
|
reportProgress(onProgress, "spectrum", mappedPercent, progress.message);
|
||||||
|
}, shouldCancel);
|
||||||
|
reportProgress(onProgress, "finalize", 97, "Finalizing result...");
|
||||||
|
const payload: FrontendAnalysisPayload = {
|
||||||
|
result: {
|
||||||
|
file_path: input.fileName,
|
||||||
|
file_size: input.fileSize,
|
||||||
|
file_type: metadata.fileType,
|
||||||
|
sample_rate: metadata.sampleRate,
|
||||||
|
channels: metadata.channels || audioBuffer.numberOfChannels,
|
||||||
|
bits_per_sample: metadata.bitsPerSample,
|
||||||
|
total_samples: totalSamples,
|
||||||
|
duration,
|
||||||
|
bit_depth: `${metadata.bitsPerSample}-bit`,
|
||||||
|
dynamic_range: dynamicRange,
|
||||||
|
peak_amplitude: peakDB,
|
||||||
|
rms_level: rmsDB,
|
||||||
|
codec_mode: metadata.codecMode,
|
||||||
|
bitrate_kbps: metadata.bitrateKbps,
|
||||||
|
total_frames: metadata.totalFrames,
|
||||||
|
codec_version: metadata.codecVersion,
|
||||||
|
spectrum,
|
||||||
|
},
|
||||||
|
samples,
|
||||||
|
};
|
||||||
|
reportProgress(onProgress, "finalize", 100, "Analysis complete");
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
await audioContext.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export const analyzeFlacFile = analyzeAudioFile;
|
||||||
|
export const analyzeFlacArrayBuffer = analyzeAudioArrayBuffer;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export const SPOTIFY_PREVIEW_VOLUME = 1;
|
||||||
@@ -21,9 +21,9 @@ export interface Settings {
|
|||||||
embedMaxQualityCover: boolean;
|
embedMaxQualityCover: boolean;
|
||||||
operatingSystem: "Windows" | "linux/MacOS";
|
operatingSystem: "Windows" | "linux/MacOS";
|
||||||
tidalQuality: "LOSSLESS" | "HI_RES_LOSSLESS";
|
tidalQuality: "LOSSLESS" | "HI_RES_LOSSLESS";
|
||||||
qobuzQuality: "6" | "7";
|
qobuzQuality: "6" | "7" | "27";
|
||||||
amazonQuality: "original";
|
amazonQuality: "original";
|
||||||
autoOrder: "tidal-qobuz-amazon" | "tidal-amazon-qobuz" | "qobuz-tidal-amazon" | "qobuz-amazon-tidal" | "amazon-tidal-qobuz" | "amazon-qobuz-tidal" | "tidal-qobuz" | "tidal-amazon" | "qobuz-tidal" | "qobuz-amazon" | "amazon-tidal" | "amazon-qobuz";
|
autoOrder: "tidal-qobuz-amazon" | "tidal-amazon-qobuz" | "qobuz-tidal-amazon" | "qobuz-amazon-tidal" | "amazon-tidal-qobuz" | "amazon-qobuz-tidal" | string;
|
||||||
autoQuality: "16" | "24";
|
autoQuality: "16" | "24";
|
||||||
allowFallback: boolean;
|
allowFallback: boolean;
|
||||||
useSpotFetchAPI: boolean;
|
useSpotFetchAPI: boolean;
|
||||||
@@ -31,6 +31,9 @@ export interface Settings {
|
|||||||
createPlaylistFolder: boolean;
|
createPlaylistFolder: boolean;
|
||||||
createM3u8File: boolean;
|
createM3u8File: boolean;
|
||||||
useFirstArtistOnly: boolean;
|
useFirstArtistOnly: boolean;
|
||||||
|
useSingleGenre: boolean;
|
||||||
|
embedGenre: boolean;
|
||||||
|
separator: "comma" | "semicolon";
|
||||||
}
|
}
|
||||||
export const FOLDER_PRESETS: Record<FolderPreset, {
|
export const FOLDER_PRESETS: Record<FolderPreset, {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -78,6 +81,7 @@ export const TEMPLATE_VARIABLES = [
|
|||||||
{ key: "{track}", description: "Track number", example: "01" },
|
{ key: "{track}", description: "Track number", example: "01" },
|
||||||
{ key: "{disc}", description: "Disc number", example: "1" },
|
{ key: "{disc}", description: "Disc number", example: "1" },
|
||||||
{ key: "{year}", description: "Release year", example: "2014" },
|
{ key: "{year}", description: "Release year", example: "2014" },
|
||||||
|
{ key: "{date}", description: "Release date (YYYY-MM-DD)", example: "2014-10-27" },
|
||||||
];
|
];
|
||||||
function detectOS(): "Windows" | "linux/MacOS" {
|
function detectOS(): "Windows" | "linux/MacOS" {
|
||||||
const platform = window.navigator.platform.toLowerCase();
|
const platform = window.navigator.platform.toLowerCase();
|
||||||
@@ -108,10 +112,13 @@ export const DEFAULT_SETTINGS: Settings = {
|
|||||||
autoQuality: "16",
|
autoQuality: "16",
|
||||||
allowFallback: true,
|
allowFallback: true,
|
||||||
useSpotFetchAPI: false,
|
useSpotFetchAPI: false,
|
||||||
spotFetchAPIUrl: "https://spotify.afkarxyz.fun/api",
|
spotFetchAPIUrl: "https://sp.afkarxyz.qzz.io/api",
|
||||||
createPlaylistFolder: true,
|
createPlaylistFolder: true,
|
||||||
createM3u8File: false,
|
createM3u8File: false,
|
||||||
useFirstArtistOnly: false
|
useFirstArtistOnly: false,
|
||||||
|
useSingleGenre: false,
|
||||||
|
embedGenre: true,
|
||||||
|
separator: "semicolon"
|
||||||
};
|
};
|
||||||
export const FONT_OPTIONS: {
|
export const FONT_OPTIONS: {
|
||||||
value: FontFamily;
|
value: FontFamily;
|
||||||
@@ -206,9 +213,6 @@ function getSettingsFromLocalStorage(): Settings {
|
|||||||
if (!('qobuzQuality' in parsed)) {
|
if (!('qobuzQuality' in parsed)) {
|
||||||
parsed.qobuzQuality = "6";
|
parsed.qobuzQuality = "6";
|
||||||
}
|
}
|
||||||
if (parsed.qobuzQuality === "27") {
|
|
||||||
parsed.qobuzQuality = "6";
|
|
||||||
}
|
|
||||||
if (!('amazonQuality' in parsed)) {
|
if (!('amazonQuality' in parsed)) {
|
||||||
parsed.amazonQuality = "original";
|
parsed.amazonQuality = "original";
|
||||||
}
|
}
|
||||||
@@ -221,6 +225,9 @@ function getSettingsFromLocalStorage(): Settings {
|
|||||||
if (!('allowFallback' in parsed)) {
|
if (!('allowFallback' in parsed)) {
|
||||||
parsed.allowFallback = true;
|
parsed.allowFallback = true;
|
||||||
}
|
}
|
||||||
|
if (!('separator' in parsed)) {
|
||||||
|
parsed.separator = "semicolon";
|
||||||
|
}
|
||||||
return { ...DEFAULT_SETTINGS, ...parsed };
|
return { ...DEFAULT_SETTINGS, ...parsed };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -285,9 +292,6 @@ export async function loadSettings(): Promise<Settings> {
|
|||||||
if (!('qobuzQuality' in parsed)) {
|
if (!('qobuzQuality' in parsed)) {
|
||||||
parsed.qobuzQuality = "6";
|
parsed.qobuzQuality = "6";
|
||||||
}
|
}
|
||||||
if (parsed.qobuzQuality === "27") {
|
|
||||||
parsed.qobuzQuality = "6";
|
|
||||||
}
|
|
||||||
if (!('amazonQuality' in parsed)) {
|
if (!('amazonQuality' in parsed)) {
|
||||||
parsed.amazonQuality = "original";
|
parsed.amazonQuality = "original";
|
||||||
}
|
}
|
||||||
@@ -309,6 +313,15 @@ export async function loadSettings(): Promise<Settings> {
|
|||||||
if (!('useFirstArtistOnly' in parsed)) {
|
if (!('useFirstArtistOnly' in parsed)) {
|
||||||
parsed.useFirstArtistOnly = false;
|
parsed.useFirstArtistOnly = false;
|
||||||
}
|
}
|
||||||
|
if (!('useSingleGenre' in parsed)) {
|
||||||
|
parsed.useSingleGenre = false;
|
||||||
|
}
|
||||||
|
if (!('embedGenre' in parsed)) {
|
||||||
|
parsed.embedGenre = true;
|
||||||
|
}
|
||||||
|
if (!('separator' in parsed)) {
|
||||||
|
parsed.separator = "semicolon";
|
||||||
|
}
|
||||||
cachedSettings = { ...DEFAULT_SETTINGS, ...parsed };
|
cachedSettings = { ...DEFAULT_SETTINGS, ...parsed };
|
||||||
return cachedSettings!;
|
return cachedSettings!;
|
||||||
}
|
}
|
||||||
@@ -334,6 +347,7 @@ export interface TemplateData {
|
|||||||
track?: number;
|
track?: number;
|
||||||
disc?: number;
|
disc?: number;
|
||||||
year?: string;
|
year?: string;
|
||||||
|
date?: string;
|
||||||
playlist?: string;
|
playlist?: string;
|
||||||
}
|
}
|
||||||
export function parseTemplate(template: string, data: TemplateData): string {
|
export function parseTemplate(template: string, data: TemplateData): string {
|
||||||
@@ -347,6 +361,7 @@ export function parseTemplate(template: string, data: TemplateData): string {
|
|||||||
result = result.replace(/\{track\}/g, data.track ? String(data.track).padStart(2, "0") : "00");
|
result = result.replace(/\{track\}/g, data.track ? String(data.track).padStart(2, "0") : "00");
|
||||||
result = result.replace(/\{disc\}/g, data.disc ? String(data.disc) : "1");
|
result = result.replace(/\{disc\}/g, data.disc ? String(data.disc) : "1");
|
||||||
result = result.replace(/\{year\}/g, data.year || "0000");
|
result = result.replace(/\{year\}/g, data.year || "0000");
|
||||||
|
result = result.replace(/\{date\}/g, data.date || "0000-00-00");
|
||||||
result = result.replace(/\{playlist\}/g, data.playlist || "");
|
result = result.replace(/\{playlist\}/g, data.playlist || "");
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -363,6 +378,7 @@ export async function saveSettings(settings: Settings): Promise<void> {
|
|||||||
cachedSettings = settings;
|
cachedSettings = settings;
|
||||||
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
|
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
|
||||||
await SaveToBackend(settings as any);
|
await SaveToBackend(settings as any);
|
||||||
|
window.dispatchEvent(new CustomEvent('settingsUpdated', { detail: settings }));
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
console.error("Failed to save settings:", error);
|
console.error("Failed to save settings:", error);
|
||||||
|
|||||||
@@ -5,41 +5,49 @@ import { getSettings } from "./settings";
|
|||||||
const toastStyle = {
|
const toastStyle = {
|
||||||
className: "font-mono lowercase",
|
className: "font-mono lowercase",
|
||||||
};
|
};
|
||||||
|
type ToastData = Parameters<typeof toast.success>[1];
|
||||||
const isSfxEnabled = () => getSettings().sfxEnabled;
|
const isSfxEnabled = () => getSettings().sfxEnabled;
|
||||||
export const toastWithSound = {
|
export const toastWithSound = {
|
||||||
success: (message: string, data?: any) => {
|
success: (message: string, data?: ToastData) => {
|
||||||
const msg = message.toLowerCase();
|
const msg = message.toLowerCase();
|
||||||
logger.success(msg);
|
logger.success(msg);
|
||||||
if (isSfxEnabled())
|
if (isSfxEnabled())
|
||||||
playSuccessSound();
|
playSuccessSound();
|
||||||
return toast.success(msg, { ...toastStyle, ...data });
|
return toast.success(msg, { ...toastStyle, ...data });
|
||||||
},
|
},
|
||||||
error: (message: string, data?: any) => {
|
error: (message: string, data?: ToastData) => {
|
||||||
const msg = message.toLowerCase();
|
const msg = message.toLowerCase();
|
||||||
logger.error(msg);
|
logger.error(msg);
|
||||||
if (isSfxEnabled())
|
if (isSfxEnabled())
|
||||||
playErrorSound();
|
playErrorSound();
|
||||||
return toast.error(msg, { ...toastStyle, ...data });
|
return toast.error(msg, { ...toastStyle, ...data });
|
||||||
},
|
},
|
||||||
warning: (message: string, data?: any) => {
|
warning: (message: string, data?: ToastData) => {
|
||||||
const msg = message.toLowerCase();
|
const msg = message.toLowerCase();
|
||||||
logger.warning(msg);
|
logger.warning(msg);
|
||||||
if (isSfxEnabled())
|
if (isSfxEnabled())
|
||||||
playWarningSound();
|
playWarningSound();
|
||||||
return toast.warning(msg, { ...toastStyle, ...data });
|
return toast.warning(msg, { ...toastStyle, ...data });
|
||||||
},
|
},
|
||||||
info: (message: string, data?: any) => {
|
info: (message: string, data?: ToastData) => {
|
||||||
const msg = message.toLowerCase();
|
const msg = message.toLowerCase();
|
||||||
logger.info(msg);
|
logger.info(msg);
|
||||||
if (isSfxEnabled())
|
if (isSfxEnabled())
|
||||||
playInfoSound();
|
playInfoSound();
|
||||||
return toast.info(msg, { ...toastStyle, ...data });
|
return toast.info(msg, { ...toastStyle, ...data });
|
||||||
},
|
},
|
||||||
message: (message: string, data?: any) => {
|
message: (message: string, data?: ToastData) => {
|
||||||
const msg = message.toLowerCase();
|
const msg = message.toLowerCase();
|
||||||
logger.info(msg);
|
logger.info(msg);
|
||||||
if (isSfxEnabled())
|
if (isSfxEnabled())
|
||||||
playInfoSound();
|
playInfoSound();
|
||||||
return toast(msg, { ...toastStyle, ...data });
|
return toast(msg, { ...toastStyle, ...data });
|
||||||
},
|
},
|
||||||
|
silentInfo: (message: string, data?: ToastData) => {
|
||||||
|
const msg = message.toLowerCase();
|
||||||
|
logger.info(msg);
|
||||||
|
return toast.info(msg, { ...toastStyle, ...data });
|
||||||
|
},
|
||||||
|
dismiss: (id?: string | number) => toast.dismiss(id),
|
||||||
|
toast: toast,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -46,3 +46,10 @@ export function openExternal(url: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export function getFirstArtist(artistString: string): string {
|
||||||
|
if (!artistString)
|
||||||
|
return artistString;
|
||||||
|
const delimiters = /[,&]|(?:\s+(?:feat\.?|ft\.?|featuring)\s+)/i;
|
||||||
|
const parts = artistString.split(delimiters);
|
||||||
|
return parts[0].trim();
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ export interface TrackMetadata {
|
|||||||
total_discs?: number;
|
total_discs?: number;
|
||||||
disc_number?: number;
|
disc_number?: number;
|
||||||
external_urls: string;
|
external_urls: string;
|
||||||
isrc: string;
|
|
||||||
album_type?: string;
|
album_type?: string;
|
||||||
spotify_id?: string;
|
spotify_id?: string;
|
||||||
album_id?: string;
|
album_id?: string;
|
||||||
@@ -109,7 +108,6 @@ export interface ArtistResponse {
|
|||||||
}
|
}
|
||||||
export type SpotifyMetadataResponse = TrackResponse | AlbumResponse | PlaylistResponse | ArtistDiscographyResponse | ArtistResponse;
|
export type SpotifyMetadataResponse = TrackResponse | AlbumResponse | PlaylistResponse | ArtistDiscographyResponse | ArtistResponse;
|
||||||
export interface DownloadRequest {
|
export interface DownloadRequest {
|
||||||
isrc: string;
|
|
||||||
service: "tidal" | "qobuz" | "amazon";
|
service: "tidal" | "qobuz" | "amazon";
|
||||||
query?: string;
|
query?: string;
|
||||||
track_name?: string;
|
track_name?: string;
|
||||||
@@ -139,6 +137,9 @@ export interface DownloadRequest {
|
|||||||
copyright?: string;
|
copyright?: string;
|
||||||
publisher?: string;
|
publisher?: string;
|
||||||
spotify_url?: string;
|
spotify_url?: string;
|
||||||
|
use_first_artist_only?: boolean;
|
||||||
|
use_single_genre?: boolean;
|
||||||
|
embed_genre?: boolean;
|
||||||
}
|
}
|
||||||
export interface DownloadResponse {
|
export interface DownloadResponse {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
@@ -154,7 +155,7 @@ export interface HealthResponse {
|
|||||||
}
|
}
|
||||||
export interface TimeSlice {
|
export interface TimeSlice {
|
||||||
time: number;
|
time: number;
|
||||||
magnitudes: number[];
|
magnitudes: number[] | Float32Array;
|
||||||
}
|
}
|
||||||
export interface SpectrumData {
|
export interface SpectrumData {
|
||||||
time_slices: TimeSlice[];
|
time_slices: TimeSlice[];
|
||||||
@@ -166,6 +167,7 @@ export interface SpectrumData {
|
|||||||
export interface AnalysisResult {
|
export interface AnalysisResult {
|
||||||
file_path: string;
|
file_path: string;
|
||||||
file_size: number;
|
file_size: number;
|
||||||
|
file_type?: "FLAC" | "MP3" | "M4A" | "AAC";
|
||||||
sample_rate: number;
|
sample_rate: number;
|
||||||
channels: number;
|
channels: number;
|
||||||
bits_per_sample: number;
|
bits_per_sample: number;
|
||||||
@@ -175,6 +177,10 @@ export interface AnalysisResult {
|
|||||||
dynamic_range: number;
|
dynamic_range: number;
|
||||||
peak_amplitude: number;
|
peak_amplitude: number;
|
||||||
rms_level: number;
|
rms_level: number;
|
||||||
|
codec_mode?: string;
|
||||||
|
bitrate_kbps?: number;
|
||||||
|
total_frames?: number;
|
||||||
|
codec_version?: string;
|
||||||
spectrum?: SpectrumData;
|
spectrum?: SpectrumData;
|
||||||
}
|
}
|
||||||
export interface LyricsDownloadRequest {
|
export interface LyricsDownloadRequest {
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
module spotiflac
|
module github.com/afkarxyz/SpotiFLAC
|
||||||
|
|
||||||
go 1.25.5
|
go 1.26
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/bogem/id3v2/v2 v2.1.4
|
github.com/bogem/id3v2/v2 v2.1.4
|
||||||
github.com/go-flac/flacpicture v0.3.0
|
github.com/go-flac/flacpicture v0.3.0
|
||||||
github.com/go-flac/flacvorbis v0.2.0
|
github.com/go-flac/flacvorbis v0.2.0
|
||||||
github.com/go-flac/go-flac v1.0.0
|
github.com/go-flac/go-flac v1.0.0
|
||||||
github.com/mewkiz/flac v1.0.13
|
|
||||||
github.com/pquerna/otp v1.5.0
|
github.com/pquerna/otp v1.5.0
|
||||||
github.com/ulikunitz/xz v0.5.15
|
github.com/ulikunitz/xz v0.5.15
|
||||||
github.com/wailsapp/wails/v2 v2.11.0
|
github.com/wailsapp/wails/v2 v2.11.0
|
||||||
go.etcd.io/bbolt v1.4.3
|
go.etcd.io/bbolt v1.4.3
|
||||||
|
golang.org/x/image v0.12.0
|
||||||
|
golang.org/x/text v0.31.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -21,7 +22,6 @@ require (
|
|||||||
github.com/godbus/dbus/v5 v5.2.0 // indirect
|
github.com/godbus/dbus/v5 v5.2.0 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/gorilla/websocket v1.5.3 // indirect
|
github.com/gorilla/websocket v1.5.3 // indirect
|
||||||
github.com/icza/bitio v1.1.0 // indirect
|
|
||||||
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
|
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
|
||||||
github.com/labstack/echo/v4 v4.13.4 // indirect
|
github.com/labstack/echo/v4 v4.13.4 // indirect
|
||||||
github.com/labstack/gommon v0.4.2 // indirect
|
github.com/labstack/gommon v0.4.2 // indirect
|
||||||
@@ -31,8 +31,6 @@ require (
|
|||||||
github.com/leaanthony/u v1.1.1 // indirect
|
github.com/leaanthony/u v1.1.1 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d // indirect
|
|
||||||
github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985 // indirect
|
|
||||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
|
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
|
||||||
github.com/pkg/errors v0.9.1 // indirect
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
@@ -45,5 +43,4 @@ require (
|
|||||||
golang.org/x/crypto v0.45.0 // indirect
|
golang.org/x/crypto v0.45.0 // indirect
|
||||||
golang.org/x/net v0.47.0 // indirect
|
golang.org/x/net v0.47.0 // indirect
|
||||||
golang.org/x/sys v0.38.0 // indirect
|
golang.org/x/sys v0.38.0 // indirect
|
||||||
golang.org/x/text v0.31.0 // indirect
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -21,10 +21,6 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
|||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/icza/bitio v1.1.0 h1:ysX4vtldjdi3Ygai5m1cWy4oLkhWTAi+SyO6HC8L9T0=
|
|
||||||
github.com/icza/bitio v1.1.0/go.mod h1:0jGnlLAx8MKMr9VGnn/4YrvZiprkvBelsVIbA9Jjr9A=
|
|
||||||
github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6 h1:8UsGZ2rr2ksmEru6lToqnXgA8Mz1DP11X4zSJ159C3k=
|
|
||||||
github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6/go.mod h1:xQig96I1VNBDIWGCdTt54nHt6EeI639SmHycLYL7FkA=
|
|
||||||
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ=
|
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ=
|
||||||
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
|
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
|
||||||
github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA=
|
github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA=
|
||||||
@@ -48,12 +44,6 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP
|
|||||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/mewkiz/flac v1.0.13 h1:6wF8rRQKBFW159Daqx6Ro7K5ZnlVhHUKfS5aTsC4oXs=
|
|
||||||
github.com/mewkiz/flac v1.0.13/go.mod h1:HfPYDA+oxjyuqMu2V+cyKcxF51KM6incpw5eZXmfA6k=
|
|
||||||
github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d h1:IL2tii4jXLdhCeQN69HNzYYW1kl0meSG0wt5+sLwszU=
|
|
||||||
github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d/go.mod h1:SIpumAnUWSy0q9RzKD3pyH3g1t5vdawUAPcW5tQrUtI=
|
|
||||||
github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985 h1:h8O1byDZ1uk6RUXMhj1QJU3VXFKXHDZxr4TXRPGeBa8=
|
|
||||||
github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985/go.mod h1:uiPmbdUbdt1NkGApKl7htQjZ8S7XaGUAVulJUJ9v6q4=
|
|
||||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
@@ -92,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.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 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
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.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-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-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-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.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 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
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-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.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 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
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=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
@@ -111,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-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.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.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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
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/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-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.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.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.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.6/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.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
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 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
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-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.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.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=
|
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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
@@ -2,8 +2,11 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"embed"
|
"embed"
|
||||||
|
"encoding/json"
|
||||||
"log"
|
"log"
|
||||||
|
|
||||||
|
"github.com/afkarxyz/SpotiFLAC/backend"
|
||||||
|
|
||||||
"github.com/wailsapp/wails/v2"
|
"github.com/wailsapp/wails/v2"
|
||||||
"github.com/wailsapp/wails/v2/pkg/options"
|
"github.com/wailsapp/wails/v2/pkg/options"
|
||||||
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
|
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
|
||||||
@@ -13,8 +16,21 @@ import (
|
|||||||
//go:embed all:frontend/dist
|
//go:embed all:frontend/dist
|
||||||
var assets embed.FS
|
var assets embed.FS
|
||||||
|
|
||||||
|
//go:embed wails.json
|
||||||
|
var wailsJSON []byte
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|
||||||
|
type wailsInfo struct {
|
||||||
|
Info struct {
|
||||||
|
ProductVersion string `json:"productVersion"`
|
||||||
|
} `json:"info"`
|
||||||
|
}
|
||||||
|
var config wailsInfo
|
||||||
|
if err := json.Unmarshal(wailsJSON, &config); err == nil && config.Info.ProductVersion != "" {
|
||||||
|
backend.AppVersion = config.Info.ProductVersion
|
||||||
|
}
|
||||||
|
|
||||||
app := NewApp()
|
app := NewApp()
|
||||||
|
|
||||||
err := wails.Run(&options.App{
|
err := wails.Run(&options.App{
|
||||||
|
|||||||
+2
-2
@@ -12,10 +12,10 @@
|
|||||||
},
|
},
|
||||||
"info": {
|
"info": {
|
||||||
"productName": "SpotiFLAC",
|
"productName": "SpotiFLAC",
|
||||||
"productVersion": "7.0.8",
|
"productVersion": "7.1.2",
|
||||||
"copyright": "© 2026 afkarxyz"
|
"copyright": "© 2026 afkarxyz"
|
||||||
},
|
},
|
||||||
"wailsjsdir": "./frontend",
|
"wailsjsdir": "./frontend",
|
||||||
"assetdir": "./frontend/dist",
|
"assetdir": "./frontend/dist",
|
||||||
"reloaddirs": "./frontend/src"
|
"reloaddirs": "./frontend/src"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user