diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml deleted file mode 100644 index 1ee451b..0000000 --- a/.github/FUNDING.yml +++ /dev/null @@ -1,2 +0,0 @@ -ko_fi: afkarxyz -patreon: afkarxyz \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml deleted file mode 100644 index ecaf71a..0000000 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ /dev/null @@ -1,77 +0,0 @@ -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: SpotiFLAC 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 diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml deleted file mode 100644 index ec4bb38..0000000 --- a/.github/ISSUE_TEMPLATE/config.yml +++ /dev/null @@ -1 +0,0 @@ -blank_issues_enabled: false \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml deleted file mode 100644 index f91add4..0000000 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ /dev/null @@ -1,36 +0,0 @@ -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 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml deleted file mode 100644 index 46bee43..0000000 --- a/.github/workflows/build.yml +++ /dev/null @@ -1,421 +0,0 @@ -name: Build Multi-Platform - -on: - push: - tags: - - 'v*' - workflow_dispatch: - -env: - GO_VERSION: '1.26' - NODE_VERSION: '24' - -jobs: - build-windows: - name: Build Windows - runs-on: windows-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Get version from tag - id: version - shell: bash - run: | - if [[ "${{ github.ref }}" == refs/tags/* ]]; then - VERSION=${GITHUB_REF#refs/tags/} - else - VERSION="dev" - fi - echo "version=$VERSION" >> $GITHUB_OUTPUT - - - name: Setup Go - uses: actions/setup-go@v5 - with: - go-version: ${{ env.GO_VERSION }} - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: ${{ env.NODE_VERSION }} - - - name: Install pnpm - uses: pnpm/action-setup@v4 - with: - version: 9 - - - name: Get pnpm store directory - shell: bash - run: | - echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV - - - name: Setup pnpm cache - continue-on-error: true - uses: actions/cache@v4 - with: - path: ${{ env.STORE_PATH }} - key: ${{ runner.os }}-pnpm-store-${{ hashFiles('frontend/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-pnpm-store- - - - name: Install Wails CLI - run: go install github.com/wailsapp/wails/v2/cmd/wails@latest - - - name: Install frontend dependencies - working-directory: frontend - run: | - pnpm install - pnpm run generate-icon - - - name: Install UPX - run: | - choco install upx -y - - - name: Build application - run: wails build -platform windows/amd64 - - - name: Compress with UPX - run: | - upx --best --lzma "build\bin\SpotiFLAC.exe" - - - name: Prepare artifacts - run: | - mkdir -p dist - Copy-Item -Path "build\bin\SpotiFLAC.exe" -Destination "dist\SpotiFLAC.exe" - - - name: Upload artifacts - uses: actions/upload-artifact@v4 - with: - name: windows-portable - path: dist/SpotiFLAC.exe - retention-days: 7 - - build-macos: - name: Build macOS - runs-on: macos-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Get version from tag - id: version - run: | - if [[ "${{ github.ref }}" == refs/tags/* ]]; then - VERSION=${GITHUB_REF#refs/tags/} - else - VERSION="dev" - fi - echo "version=$VERSION" >> $GITHUB_OUTPUT - - - name: Setup Go - uses: actions/setup-go@v5 - with: - go-version: ${{ env.GO_VERSION }} - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: ${{ env.NODE_VERSION }} - - - name: Install pnpm - uses: pnpm/action-setup@v4 - with: - version: 9 - - - name: Get pnpm store directory - run: | - echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV - - - name: Setup pnpm cache - continue-on-error: true - uses: actions/cache@v4 - with: - path: ${{ env.STORE_PATH }} - key: ${{ runner.os }}-pnpm-store-${{ hashFiles('frontend/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-pnpm-store- - - - name: Install Wails CLI - run: go install github.com/wailsapp/wails/v2/cmd/wails@latest - - - name: Install frontend dependencies - working-directory: frontend - run: | - pnpm install - pnpm run generate-icon - - - name: Build application - run: wails build -platform darwin/universal - - - name: Create DMG - run: | - mkdir -p dist - # Install create-dmg if not available - brew install create-dmg || true - - # Create DMG - create-dmg \ - --volname "SpotiFLAC" \ - --window-pos 200 120 \ - --window-size 600 400 \ - --icon-size 100 \ - --icon "SpotiFLAC.app" 175 120 \ - --hide-extension "SpotiFLAC.app" \ - --app-drop-link 425 120 \ - "dist/SpotiFLAC.dmg" \ - "build/bin/SpotiFLAC.app" || \ - # Fallback to hdiutil if create-dmg fails - hdiutil create -volname SpotiFLAC -srcfolder build/bin/SpotiFLAC.app -ov -format UDZO dist/SpotiFLAC.dmg - - - name: Upload artifacts - uses: actions/upload-artifact@v4 - with: - name: macos-portable - path: dist/SpotiFLAC.dmg - retention-days: 7 - - build-linux: - name: Build Linux (${{ matrix.display_name }}) - runs-on: ${{ matrix.runner }} - strategy: - fail-fast: false - matrix: - include: - - display_name: amd64 - runner: ubuntu-24.04 - wails_platform: linux/amd64 - artifact_name: linux-portable - output_name: SpotiFLAC.AppImage - appimage_arch: x86_64 - appimagetool_arch: x86_64 - pkgconfig_dir: /usr/lib/x86_64-linux-gnu/pkgconfig - - display_name: arm64 - runner: ubuntu-24.04-arm - wails_platform: linux/arm64 - artifact_name: linux-portable-arm - output_name: SpotiFLAC-ARM.AppImage - appimage_arch: aarch64 - appimagetool_arch: aarch64 - pkgconfig_dir: /usr/lib/aarch64-linux-gnu/pkgconfig - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Get version from tag - id: version - run: | - if [[ "${{ github.ref }}" == refs/tags/* ]]; then - VERSION=${GITHUB_REF#refs/tags/} - else - VERSION="dev" - fi - echo "version=$VERSION" >> $GITHUB_OUTPUT - - - name: Setup Go - uses: actions/setup-go@v5 - with: - go-version: ${{ env.GO_VERSION }} - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: ${{ env.NODE_VERSION }} - - - name: Install pnpm - uses: pnpm/action-setup@v4 - with: - version: 9 - - - name: Get pnpm store directory - run: | - echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV - - - name: Setup pnpm cache - continue-on-error: true - uses: actions/cache@v4 - with: - path: ${{ env.STORE_PATH }} - key: ${{ runner.os }}-pnpm-store-${{ hashFiles('frontend/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-pnpm-store- - - - name: Install dependencies - run: | - sudo apt-get update - sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libfuse2 imagemagick upx-ucl - - # Create symlink for webkit2gtk-4.0 -> webkit2gtk-4.1 (Ubuntu 24.04 compatibility) - sudo ln -sf "${{ matrix.pkgconfig_dir }}/webkit2gtk-4.1.pc" "${{ matrix.pkgconfig_dir }}/webkit2gtk-4.0.pc" - - - name: Install Wails CLI - run: go install github.com/wailsapp/wails/v2/cmd/wails@latest - - - name: Install frontend dependencies - working-directory: frontend - run: | - pnpm install - pnpm run generate-icon - - - name: Build application - run: wails build -platform ${{ matrix.wails_platform }} - - - name: Compress with UPX - run: | - upx --best --lzma build/bin/SpotiFLAC - - - name: Cache appimagetool - id: cache-appimagetool - uses: actions/cache@v4 - with: - path: appimagetool - key: appimagetool-${{ matrix.appimagetool_arch }}-v2 - - - name: Download appimagetool - if: steps.cache-appimagetool.outputs.cache-hit != 'true' - run: | - wget --timeout=30 --tries=5 --retry-connrefused --waitretry=5 -O appimagetool "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-${{ matrix.appimagetool_arch }}.AppImage" || \ - wget --timeout=30 --tries=5 --retry-connrefused --waitretry=5 -O appimagetool "https://github.com/AppImage/appimagetool/releases/download/1.9.1/appimagetool-${{ matrix.appimagetool_arch }}.AppImage" - - - name: Make appimagetool executable - run: chmod +x appimagetool - - - name: Create AppImage - run: | - mkdir -p AppDir/usr/bin - mkdir -p AppDir/usr/share/applications - mkdir -p AppDir/usr/share/icons/hicolor/256x256/apps - - # Copy binary - cp build/bin/SpotiFLAC AppDir/usr/bin/ - - # Create desktop file - cat > AppDir/spotiflac.desktop << 'EOF' - [Desktop Entry] - Name=SpotiFLAC - Exec=SpotiFLAC - Icon=spotiflac - Type=Application - Categories=Audio;AudioVideo; - Comment=Get Spotify tracks in true FLAC from Tidal/Deezer - EOF - - cp AppDir/spotiflac.desktop AppDir/usr/share/applications/ - - # Create icon - if [ -f "build/appicon.png" ]; then - convert build/appicon.png -resize 256x256 AppDir/spotiflac.png - elif [ -f "frontend/public/icon.svg" ]; then - convert -background none -size 256x256 frontend/public/icon.svg AppDir/spotiflac.png - else - echo "Warning: No icon found, building without icon" - fi - - # Copy icon if exists - if [ -f "AppDir/spotiflac.png" ]; then - cp AppDir/spotiflac.png AppDir/usr/share/icons/hicolor/256x256/apps/ - cp AppDir/spotiflac.png AppDir/.DirIcon - fi - - # Create AppRun - cat > AppDir/AppRun << 'EOF' - #!/bin/sh - SELF=$(readlink -f "$0") - HERE=${SELF%/*} - export PATH="${HERE}/usr/bin/:${PATH}" - export LD_LIBRARY_PATH="${HERE}/usr/lib/:${LD_LIBRARY_PATH}" - exec "${HERE}/usr/bin/SpotiFLAC" "$@" - EOF - chmod +x AppDir/AppRun - - # Create AppImage - mkdir -p dist - ARCH=${{ matrix.appimage_arch }} ./appimagetool --no-appstream AppDir "dist/${{ matrix.output_name }}" - - - name: Upload artifacts - uses: actions/upload-artifact@v4 - with: - name: ${{ matrix.artifact_name }} - path: dist/${{ matrix.output_name }} - retention-days: 7 - - create-release: - name: Create Release - needs: [build-windows, build-macos, build-linux] - runs-on: ubuntu-latest - if: startsWith(github.ref, 'refs/tags/v') - permissions: - contents: write - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Get version from tag - id: version - run: | - VERSION=${GITHUB_REF#refs/tags/} - echo "version=$VERSION" >> $GITHUB_OUTPUT - - - name: Download all artifacts - uses: actions/download-artifact@v4 - with: - path: artifacts - - - name: Display structure of downloaded files - run: ls -R artifacts - - - name: Create Release - uses: softprops/action-gh-release@v2 - with: - draft: true - prerelease: false - generate_release_notes: false - body: | - ## Changelog - - ## Downloads - - - `SpotiFLAC.exe` - Windows - - `SpotiFLAC.dmg` - macOS - - `SpotiFLAC.AppImage` - Linux AMD64 - - `SpotiFLAC-ARM.AppImage` - Linux ARM64 - -
- Linux Requirements - - The AppImage requires `webkit2gtk-4.1` to be installed on your system: - - **Ubuntu/Debian:** - ```bash - sudo apt install libwebkit2gtk-4.1-0 - ``` - - **Arch Linux:** - ```bash - sudo pacman -S webkit2gtk-4.1 - ``` - - **Fedora:** - ```bash - sudo dnf install webkit2gtk4.1 - ``` - - After installing the dependency, make the AppImage executable: - ```bash - chmod +x SpotiFLAC.AppImage - ./SpotiFLAC.AppImage - ``` - - For ARM64: - ```bash - chmod +x SpotiFLAC-ARM.AppImage - ./SpotiFLAC-ARM.AppImage - ``` - -
- files: | - artifacts/windows-portable/SpotiFLAC.exe - artifacts/macos-portable/SpotiFLAC.dmg - artifacts/linux-portable/SpotiFLAC.AppImage - artifacts/linux-portable-arm/SpotiFLAC-ARM.AppImage - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 0fd4e72..0000000 --- a/.gitignore +++ /dev/null @@ -1,64 +0,0 @@ -# Wails Build -build/ -*.exe -*.dll -*.dylib -*.so - -# Wails Generated Files -frontend/wailsjs/ - -# Go -*.test -*.out -go.work -go.work.sum - -# Node / Frontend -node_modules/ -frontend/node_modules/ -frontend/dist/ -frontend/.vite/ -npm-debug.log* -yarn-debug.log* -yarn-error.log* -pnpm-debug.log* -.pnpm-store/ -.npm -.yarn -*.tsbuildinfo - -# IDE / Editors -.vscode/ -.idea/ -*.swp -*.swo -*~ -.DS_Store - -# OS -Thumbs.db -desktop.ini - -# Environment -.env -.env.local -.env.*.local - -# Logs -*.log -logs/ - -# Temporary files -tmp/ -temp/ -*.tmp -*.bak -*.old - -# Test files -test - -# Build notes (optional - uncomment if you don't want to commit) -# BUILD_NOTES.md -push.bat \ No newline at end of file diff --git a/LICENSE b/LICENSE deleted file mode 100644 index 705657f..0000000 --- a/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2026 afkarxyz - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/README.md b/README.md deleted file mode 100644 index 7137312..0000000 --- a/README.md +++ /dev/null @@ -1,115 +0,0 @@ -# SpotiFLAC - -afkarxyz%2FSpotiFLAC | Trendshift - -Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account required. - -![Windows](https://img.shields.io/badge/Windows-10%2B-0078D6?style=for-the-badge&logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI1MTIiIGhlaWdodD0iNTEyIiB2aWV3Qm94PSIwIDAgMjAgMjAiPjxwYXRoIGZpbGw9IiNmZmZmZmYiIGZpbGwtcnVsZT0iZXZlbm9kZCIgZD0iTTIwIDEwLjg3M1YyMEw4LjQ3OSAxOC41MzdsLjAwMS03LjY2NEgyMFptLTEzLjEyIDBsLS4wMDEgNy40NjFMMCAxNy40NjF2LTYuNTg4aDYuODhaTTIwIDkuMjczSDguNDhsLS4wMDEtNy44MUwyMCAwdjkuMjczWk02Ljg3OSAxLjY2NmwuMDAxIDcuNjA3SDBWMi41MzlsNi44NzktLjg3M1oiLz48L3N2Zz4=) -![macOS](https://img.shields.io/badge/macOS-10.13%2B-000000?style=for-the-badge&logo=apple&logoColor=white) -![Linux](https://img.shields.io/badge/Linux-Any-FCC624?style=for-the-badge&logo=linux&logoColor=white) -[![Announcements](https://img.shields.io/badge/ANNOUNCEMENTS-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white)](https://t.me/spotiflac) -[![Chat](https://img.shields.io/badge/CHAT-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white)](https://t.me/spotiflac_chat) - -### [Download](https://github.com/spotbye/SpotiFLAC/releases) - -![Image](https://github.com/user-attachments/assets/c2624ca5-8569-49f0-950e-4410b523cea1) - -## Other projects - -### [SpotiFLAC Next](https://github.com/spotbye/SpotiFLAC-Next) - -Get Spotify tracks in true Lossless from Tidal, Qobuz, Amazon Music, Deezer & Apple Music — no account required. - -### [SpotubeDL.com](https://spotubedl.com) - -Download Spotify Tracks, Albums, Playlists & Discography as MP3/OGG/Opus. - -## Related projects - -### [SpotiFLAC (Mobile)](https://github.com/zarzet/SpotiFLAC-Mobile) - -SpotiFLAC for Android & iOS — maintained by [@zarzet](https://github.com/zarzet) - -### [SpotiFLAC (Python Module)](https://github.com/ShuShuzinhuu/SpotiFLAC-Module-Version) - -SpotiFLAC Python library for SpotiFLAC integration — maintained by [@ShuShuzinhuu](https://github.com/ShuShuzinhuu) - -## FAQ - -
-Is this software free? - -_Yes. This software is completely free. -You do not need an account, login, or subscription. -All you need is an internet connection._ - -
- -
-Can using this software get my Spotify account suspended or banned? - -_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._ - -
- -
-Where does the audio come from? - -_The audio is fetched using third-party APIs._ - -
- -
-Why does metadata fetching sometimes fail? - -_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._ - -
- -
-Why does Windows Defender or antivirus flag or delete the file? - -_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._ - -
- -
-Want to support the project? - -_If this software is useful and brings you value, -consider supporting the project by buying me a coffee. -Your support helps keep development going._ - -
- -[![Ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/afkarxyz) - -## Disclaimer - -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. - -You are solely responsible for: - -1. Ensuring your use of this software complies with your local laws. -2. Reading and adhering to the Terms of Service of the respective platforms. -3. Any legal consequences resulting from the misuse of this tool. - -The software is provided "as is", without warranty of any kind. The author assumes no liability for any bans, damages, or legal issues arising from its use. - -## API Credits - -[MusicBrainz](https://musicbrainz.org) · [LRCLIB](https://lrclib.net) · [Songlink/Odesli](https://song.link) · [hifi-api](https://github.com/binimum/hifi-api) · [WJHE](https://music.wjhe.top) · [GDStudio](https://music.gdstudio.xyz) · [MusicDL](https://musicdl.me) - -> [!TIP] -> -> **Star Us**, You will receive all release notifications from GitHub without any delay ~ - -[![RepoStars](https://repostars.dev/api/embed?repo=afkarxyz%2FSpotiFLAC&theme=forest)](https://repostars.dev/?repos=afkarxyz%2FSpotiFLAC&theme=forest) diff --git a/app.go b/app.go deleted file mode 100644 index 82a8e32..0000000 --- a/app.go +++ /dev/null @@ -1,2420 +0,0 @@ -package main - -import ( - "context" - "encoding/base64" - "encoding/json" - "errors" - "fmt" - "io" - "net/url" - "os" - - "path/filepath" - - "net/http" - "strings" - "sync" - "time" - - "github.com/afkarxyz/SpotiFLAC/backend" - - "github.com/wailsapp/wails/v2/pkg/runtime" -) - -type App struct { - ctx context.Context -} - -type CurrentIPInfo struct { - IP string `json:"ip"` - Country string `json:"country"` - CountryCode string `json:"country_code,omitempty"` - Source string `json:"source,omitempty"` -} - -type APIStatusTargetResult struct { - Target string `json:"target"` - Label string `json:"label"` - Online bool `json:"online"` - Message string `json:"message,omitempty"` -} - -type APIStatusReport struct { - Type string `json:"type"` - Online bool `json:"online"` - RequireAll bool `json:"require_all"` - Details []APIStatusTargetResult `json:"details"` -} - -const checkOperationTimeout = 10 * time.Second - -func NewApp() *App { - return &App{} -} - -func (a *App) LogStatusConsole(level string, message string) { - normalizedLevel := strings.ToLower(strings.TrimSpace(level)) - if normalizedLevel == "" { - normalizedLevel = "info" - } - - line := fmt.Sprintf("[%s] [%s] %s\n", time.Now().Format("15:04:05"), normalizedLevel, strings.TrimSpace(message)) - switch normalizedLevel { - case "error": - _, _ = fmt.Fprint(os.Stderr, line) - default: - fmt.Print(line) - } -} - -type timedResult[T any] struct { - value T - err error -} - -func runWithTimeout[T any](timeout time.Duration, fn func() (T, error)) (T, error) { - resultCh := make(chan timedResult[T], 1) - - go func() { - value, err := fn() - resultCh <- timedResult[T]{value: value, err: err} - }() - - select { - case result := <-resultCh: - return result.value, result.err - case <-time.After(timeout): - var zero T - return zero, fmt.Errorf("operation timed out after %s", timeout) - } -} - -func containsStreamingURL(body []byte) bool { - trimmedBody := strings.TrimSpace(string(body)) - if trimmedBody == "" { - return false - } - - var directResp struct { - URL string `json:"url"` - } - if err := json.Unmarshal(body, &directResp); err == nil && isStreamingURL(directResp.URL) { - return true - } - - var nestedResp struct { - Data struct { - URL string `json:"url"` - } `json:"data"` - } - if err := json.Unmarshal(body, &nestedResp); err == nil && isStreamingURL(nestedResp.Data.URL) { - return true - } - - return isStreamingURL(trimmedBody) -} - -func containsLRCLIBResults(body []byte) bool { - trimmedBody := strings.TrimSpace(string(body)) - if trimmedBody == "" { - return false - } - - var searchResults []map[string]interface{} - if err := json.Unmarshal(body, &searchResults); err == nil { - return len(searchResults) > 0 - } - - var exactResult map[string]interface{} - if err := json.Unmarshal(body, &exactResult); err == nil { - return len(exactResult) > 0 - } - - return false -} - -func containsMusicBrainzResults(body []byte) bool { - trimmedBody := strings.TrimSpace(string(body)) - if trimmedBody == "" { - return false - } - - var payload struct { - Count int `json:"count"` - Recordings []json.RawMessage `json:"recordings"` - } - if err := json.Unmarshal(body, &payload); err != nil { - return false - } - - return payload.Count > 0 || len(payload.Recordings) > 0 -} - -func isStreamingURL(raw string) bool { - candidate := strings.TrimSpace(raw) - if candidate == "" { - return false - } - - parsed, err := url.Parse(candidate) - if err != nil { - return false - } - - return (parsed.Scheme == "http" || parsed.Scheme == "https") && parsed.Host != "" -} - -func previewResponseBody(body []byte, maxLen int) string { - preview := strings.TrimSpace(string(body)) - if maxLen > 0 && len(preview) > maxLen { - return preview[:maxLen] + "..." - } - return preview -} - -func fetchCurrentIPInfo() (CurrentIPInfo, error) { - type ipwhoisResponse struct { - Success bool `json:"success"` - IP string `json:"ip"` - Country string `json:"country"` - CountryCode string `json:"country_code"` - Message string `json:"message"` - } - type ipapiResponse struct { - IP string `json:"ip"` - Country string `json:"country_name"` - CountryCode string `json:"country_code"` - Error bool `json:"error"` - Reason string `json:"reason"` - } - - client := &http.Client{Timeout: 8 * time.Second} - tryFetch := func(source, reqURL string, parse func(body []byte) (CurrentIPInfo, error)) (CurrentIPInfo, error) { - req, err := http.NewRequest(http.MethodGet, reqURL, nil) - if err != nil { - return CurrentIPInfo{}, err - } - req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36") - req.Header.Set("Accept", "application/json") - - resp, err := client.Do(req) - if err != nil { - return CurrentIPInfo{}, err - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return CurrentIPInfo{}, err - } - if resp.StatusCode != http.StatusOK { - return CurrentIPInfo{}, fmt.Errorf("%s returned status %d: %s", source, resp.StatusCode, previewResponseBody(body, 200)) - } - - info, err := parse(body) - if err != nil { - return CurrentIPInfo{}, err - } - info.Source = source - return info, nil - } - - info, err := tryFetch("ipwho.is", "https://ipwho.is/", func(body []byte) (CurrentIPInfo, error) { - var payload ipwhoisResponse - if err := json.Unmarshal(body, &payload); err != nil { - return CurrentIPInfo{}, err - } - if !payload.Success { - return CurrentIPInfo{}, fmt.Errorf("ipwho.is lookup failed: %s", strings.TrimSpace(payload.Message)) - } - if strings.TrimSpace(payload.IP) == "" || strings.TrimSpace(payload.Country) == "" { - return CurrentIPInfo{}, fmt.Errorf("ipwho.is returned incomplete IP data") - } - return CurrentIPInfo{ - IP: strings.TrimSpace(payload.IP), - Country: strings.TrimSpace(payload.Country), - CountryCode: strings.TrimSpace(payload.CountryCode), - }, nil - }) - if err == nil { - return info, nil - } - firstErr := err - - info, err = tryFetch("ipapi.co", "https://ipapi.co/json/", func(body []byte) (CurrentIPInfo, error) { - var payload ipapiResponse - if err := json.Unmarshal(body, &payload); err != nil { - return CurrentIPInfo{}, err - } - if payload.Error { - return CurrentIPInfo{}, fmt.Errorf("ipapi.co lookup failed: %s", strings.TrimSpace(payload.Reason)) - } - if strings.TrimSpace(payload.IP) == "" || strings.TrimSpace(payload.Country) == "" { - return CurrentIPInfo{}, fmt.Errorf("ipapi.co returned incomplete IP data") - } - return CurrentIPInfo{ - IP: strings.TrimSpace(payload.IP), - Country: strings.TrimSpace(payload.Country), - CountryCode: strings.TrimSpace(payload.CountryCode), - }, nil - }) - if err == nil { - return info, nil - } - - return CurrentIPInfo{}, fmt.Errorf("failed to detect public IP: %v; fallback failed: %v", firstErr, err) -} - -func (a *App) GetCurrentIPInfo() (string, error) { - info, err := fetchCurrentIPInfo() - if err != nil { - return "", err - } - - payload, err := json.Marshal(info) - if err != nil { - return "", err - } - - return string(payload), nil -} - -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) { - a.ctx = ctx - - if err := backend.InitHistoryDB("SpotiFLAC"); err != nil { - fmt.Printf("Failed to init history DB: %v\n", err) - } - if err := backend.InitISRCCacheDB(); err != nil { - fmt.Printf("Failed to init ISRC cache DB: %v\n", err) - } - if err := backend.InitProviderPriorityDB(); err != nil { - fmt.Printf("Failed to init provider priority DB: %v\n", err) - } - if err := backend.CleanupLegacyTidalPublicAPIState(); err != nil { - fmt.Printf("Failed to clean legacy Tidal API cache: %v\n", err) - } - if err := backend.SanitizePersistedConfigSettings(); err != nil { - fmt.Printf("Failed to sanitize persisted config settings: %v\n", err) - } -} - -func (a *App) shutdown(ctx context.Context) { - backend.CloseHistoryDB() - backend.CloseISRCCacheDB() - backend.CloseProviderPriorityDB() -} - -type SpotifyMetadataRequest struct { - URL string `json:"url"` - Batch bool `json:"batch"` - Delay float64 `json:"delay"` - Timeout float64 `json:"timeout"` - Separator string `json:"separator,omitempty"` -} - -type DownloadRequest struct { - Service string `json:"service"` - Query string `json:"query,omitempty"` - TrackName string `json:"track_name,omitempty"` - ArtistName string `json:"artist_name,omitempty"` - AlbumName string `json:"album_name,omitempty"` - AlbumArtist string `json:"album_artist,omitempty"` - ReleaseDate string `json:"release_date,omitempty"` - CoverURL string `json:"cover_url,omitempty"` - TidalAPIURL string `json:"tidal_api_url,omitempty"` - OutputDir string `json:"output_dir,omitempty"` - AudioFormat string `json:"audio_format,omitempty"` - FilenameFormat string `json:"filename_format,omitempty"` - TrackNumber bool `json:"track_number,omitempty"` - Position int `json:"position,omitempty"` - UseAlbumTrackNumber bool `json:"use_album_track_number,omitempty"` - SpotifyID string `json:"spotify_id,omitempty"` - EmbedLyrics bool `json:"embed_lyrics,omitempty"` - EmbedMaxQualityCover bool `json:"embed_max_quality_cover,omitempty"` - ServiceURL string `json:"service_url,omitempty"` - Duration int `json:"duration,omitempty"` - ItemID string `json:"item_id,omitempty"` - SpotifyTrackNumber int `json:"spotify_track_number,omitempty"` - SpotifyDiscNumber int `json:"spotify_disc_number,omitempty"` - SpotifyTotalTracks int `json:"spotify_total_tracks,omitempty"` - SpotifyTotalDiscs int `json:"spotify_total_discs,omitempty"` - ISRC string `json:"isrc,omitempty"` - Copyright string `json:"copyright,omitempty"` - Publisher string `json:"publisher,omitempty"` - Composer string `json:"composer,omitempty"` - PlaylistName string `json:"playlist_name,omitempty"` - PlaylistOwner string `json:"playlist_owner,omitempty"` - 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 { - Success bool `json:"success"` - Message string `json:"message"` - File string `json:"file,omitempty"` - Error string `json:"error,omitempty"` - AlreadyExists bool `json:"already_exists,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) { - if spotifyTrackID == "" { - return "", fmt.Errorf("spotify track ID is required") - } - - fmt.Printf("[GetStreamingURLs] Called for track ID: %s, Region: %s\n", spotifyTrackID, region) - client := backend.NewSongLinkClient() - urls, err := client.GetAllURLsFromSpotify(spotifyTrackID, region) - if err != nil { - return "", err - } - - jsonData, err := json.Marshal(urls) - if err != nil { - return "", fmt.Errorf("failed to encode response: %v", err) - } - - return string(jsonData), nil -} - -func (a *App) GetSpotifyMetadata(req SpotifyMetadataRequest) (string, error) { - if req.URL == "" { - return "", fmt.Errorf("URL parameter is required") - } - - if req.Delay == 0 { - req.Delay = 1.0 - } - if req.Timeout == 0 { - req.Timeout = 300.0 - } - - ctx, cancel := context.WithTimeout(context.Background(), time.Duration(req.Timeout*float64(time.Second))) - defer cancel() - - 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 = ", " - } - } - } - } - - 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 { - return "", fmt.Errorf("failed to fetch metadata: %v", err) - } - - jsonData, err := json.MarshalIndent(data, "", " ") - if err != nil { - return "", fmt.Errorf("failed to encode response: %v", err) - } - - return string(jsonData), nil -} - -type SpotifySearchRequest struct { - Query string `json:"query"` - Limit int `json:"limit"` -} - -func (a *App) SearchSpotify(req SpotifySearchRequest) (*backend.SearchResponse, error) { - if req.Query == "" { - return nil, fmt.Errorf("search query is required") - } - - if req.Limit <= 0 { - req.Limit = 10 - } - - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - return backend.SearchSpotify(ctx, req.Query, req.Limit) -} - -type SpotifySearchByTypeRequest struct { - Query string `json:"query"` - SearchType string `json:"search_type"` - Limit int `json:"limit"` - Offset int `json:"offset"` -} - -func (a *App) SearchSpotifyByType(req SpotifySearchByTypeRequest) ([]backend.SearchResult, error) { - if req.Query == "" { - return nil, fmt.Errorf("search query is required") - } - - if req.SearchType == "" { - return nil, fmt.Errorf("search type is required") - } - - if req.Limit <= 0 { - req.Limit = 50 - } - - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - return backend.SearchSpotifyByType(ctx, req.Query, req.SearchType, req.Limit, req.Offset) -} - -func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) { - - if req.Service == "qobuz" && req.SpotifyID == "" { - return DownloadResponse{ - Success: false, - Error: "Spotify ID is required for Qobuz", - }, fmt.Errorf("spotify ID is required for Qobuz") - } - - if req.Service == "" { - req.Service = "tidal" - } - - if req.OutputDir == "" { - req.OutputDir = "." - } else { - - if req.PlaylistName != "" { - sanitizedPlaylist := backend.SanitizeFilename(req.PlaylistName) - req.OutputDir = filepath.Join(req.OutputDir, sanitizedPlaylist) - } - - req.OutputDir = backend.SanitizeFolderPath(req.OutputDir) - } - - if req.AudioFormat == "" { - req.AudioFormat = "LOSSLESS" - } - - var err error - var filename string - - if req.FilenameFormat == "" { - req.FilenameFormat = "title-artist" - } - shouldResolveISRC := strings.Contains(req.FilenameFormat, "{isrc}") || backend.GetExistingFileCheckModeSetting() == "isrc" - if req.ISRC == "" && shouldResolveISRC && req.SpotifyID != "" { - req.ISRC = backend.ResolveTrackISRC(req.SpotifyID) - } - - itemID := req.ItemID - if itemID == "" { - - if req.SpotifyID != "" { - itemID = fmt.Sprintf("%s-%d", req.SpotifyID, time.Now().UnixNano()) - } else { - itemID = fmt.Sprintf("%s-%s-%d", req.TrackName, req.ArtistName, time.Now().UnixNano()) - } - - backend.AddToQueue(itemID, req.TrackName, req.ArtistName, req.AlbumName, req.SpotifyID) - } - - backend.SetDownloading(true) - backend.StartDownloadItem(itemID) - defer backend.SetDownloading(false) - - spotifyURL := "" - if req.SpotifyID != "" { - spotifyURL = fmt.Sprintf("https://open.spotify.com/track/%s", req.SpotifyID) - } - - 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 = ", " - } - } - } - } - - if req.SpotifyID != "" && (req.Copyright == "" || req.Publisher == "" || req.Composer == "" || req.SpotifyTotalDiscs == 0 || req.ReleaseDate == "" || req.SpotifyTotalTracks == 0 || req.SpotifyTrackNumber == 0) { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - trackURL := fmt.Sprintf("https://open.spotify.com/track/%s", req.SpotifyID) - trackData, err := backend.GetFilteredSpotifyData(ctx, trackURL, false, 0, metadataSeparator, nil) - if err == nil { - - var trackResp struct { - Track struct { - Copyright string `json:"copyright"` - Publisher string `json:"publisher"` - Composer string `json:"composer"` - TotalDiscs int `json:"total_discs"` - TotalTracks int `json:"total_tracks"` - TrackNumber int `json:"track_number"` - ReleaseDate string `json:"release_date"` - } `json:"track"` - } - if jsonData, jsonErr := json.Marshal(trackData); jsonErr == nil { - if json.Unmarshal(jsonData, &trackResp) == nil { - - if req.Copyright == "" && trackResp.Track.Copyright != "" { - req.Copyright = trackResp.Track.Copyright - } - if req.Publisher == "" && trackResp.Track.Publisher != "" { - req.Publisher = trackResp.Track.Publisher - } - if req.Composer == "" && trackResp.Track.Composer != "" { - req.Composer = trackResp.Track.Composer - } - if req.SpotifyTotalDiscs == 0 && trackResp.Track.TotalDiscs > 0 { - req.SpotifyTotalDiscs = trackResp.Track.TotalDiscs - } - if req.SpotifyTotalTracks == 0 && trackResp.Track.TotalTracks > 0 { - req.SpotifyTotalTracks = trackResp.Track.TotalTracks - } - if req.SpotifyTrackNumber == 0 && trackResp.Track.TrackNumber > 0 { - req.SpotifyTrackNumber = trackResp.Track.TrackNumber - } - if req.ReleaseDate == "" && trackResp.Track.ReleaseDate != "" { - req.ReleaseDate = trackResp.Track.ReleaseDate - } - } - } - } - } - - if req.TrackName != "" && req.ArtistName != "" { - expectedFilename := backend.BuildExpectedFilename(req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.FilenameFormat, req.PlaylistName, req.PlaylistOwner, req.TrackNumber, req.Position, req.SpotifyDiscNumber, req.UseAlbumTrackNumber, req.ISRC) - expectedPath := filepath.Join(req.OutputDir, expectedFilename) - - if !backend.GetRedownloadWithSuffixSetting() { - if fileInfo, err := os.Stat(expectedPath); err == nil && fileInfo.Size() > 100*1024 { - - backend.SkipDownloadItem(itemID, expectedPath) - return DownloadResponse{ - Success: true, - Message: "File already exists", - File: expectedPath, - AlreadyExists: true, - ItemID: itemID, - }, nil - } - } - } - - 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, err := client.GetISRCDirect(req.SpotifyID) - if err != nil { - fmt.Printf("Warning: failed to resolve ISRC for Qobuz: %v\n", err) - } - isrcChan <- isrc - }() - } else { - close(isrcChan) - } - } else { - close(lyricsChan) - close(isrcChan) - } - - switch req.Service { - case "amazon": - - downloader := backend.NewAmazonDownloader() - 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, req.Composer, metadataSeparator, req.ISRC, spotifyURL, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre) - } else { - 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, req.Composer, metadataSeparator, req.ISRC, spotifyURL, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre) - } - - case "tidal": - if !strings.HasPrefix(strings.TrimRight(strings.TrimSpace(req.TidalAPIURL), "/"), "https://") { - err = fmt.Errorf("a configured HTTPS Tidal instance is required") - break - } - downloader := backend.NewTidalDownloader(req.TidalAPIURL) - 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, req.Composer, metadataSeparator, req.ISRC, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre) - } else { - 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, req.Composer, metadataSeparator, req.ISRC, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre) - } - - case "qobuz": - - isrc := strings.TrimSpace(req.ISRC) - if isrc == "" { - fmt.Println("Waiting for ISRC (Qobuz dependency)...") - isrc = <-isrcChan - } - downloader := backend.NewQobuzDownloader() - quality := req.AudioFormat - if quality == "" { - quality = "6" - } - filename, err = downloader.DownloadTrackWithISRC(isrc, 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, req.Composer, metadataSeparator, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre) - - default: - return DownloadResponse{ - Success: false, - Error: fmt.Sprintf("Unknown service: %s", req.Service), - }, fmt.Errorf("unknown service: %s", req.Service) - } - - if err != nil { - backend.FailDownloadItem(itemID, fmt.Sprintf("Download failed: %v", err)) - - if filename != "" && !strings.HasPrefix(filename, "EXISTS:") { - - if _, statErr := os.Stat(filename); statErr == nil { - fmt.Printf("Removing corrupted/partial file after failed download: %s\n", filename) - if removeErr := os.Remove(filename); removeErr != nil { - fmt.Printf("Warning: Failed to remove corrupted file %s: %v\n", filename, removeErr) - } - } - } - - return DownloadResponse{ - Success: false, - Error: fmt.Sprintf("Download failed: %v", err), - ItemID: itemID, - }, err - } - - alreadyExists := false - if strings.HasPrefix(filename, "EXISTS:") { - alreadyExists = true - filename = strings.TrimPrefix(filename, "EXISTS:") - } - - if !alreadyExists { - validated, validationErr := backend.ValidateDownloadedTrackDuration(filename, req.Duration) - if validationErr != nil { - cleanupInvalidDownloadArtifacts(filename) - errorMessage := validationErr.Error() - backend.FailDownloadItem(itemID, errorMessage) - return DownloadResponse{ - Success: false, - Error: errorMessage, - ItemID: itemID, - }, errors.New(errorMessage) - } - if !validated { - fmt.Printf("[DownloadValidation] Skipped duration validation for %s (expected=%ds)\n", filename, req.Duration) - } - } - - if !alreadyExists && req.SpotifyID != "" && req.EmbedLyrics && (strings.HasSuffix(filename, ".flac") || strings.HasSuffix(filename, ".mp3") || strings.HasSuffix(filename, ".m4a")) { - fmt.Printf("\nWaiting for lyrics fetch to complete...\n") - lyrics := <-lyricsChan - if lyrics != "" { - fmt.Printf("\n--- Full LRC Content ---\n") - fmt.Println(lyrics) - fmt.Printf("--- End LRC Content ---\n\n") - - fmt.Printf("Embedding into: %s\n", filename) - - if err := backend.EmbedLyricsOnlyUniversal(filename, lyrics); err != nil { - fmt.Printf("Failed to embed lyrics: %v\n", err) - } else { - fmt.Printf("Lyrics embedded successfully!\n") - } - } else { - fmt.Println("No lyrics found to embed.") - } - } else { - - select { - case <-lyricsChan: - default: - } - } - - message := "Download completed successfully" - if alreadyExists { - message = "File already exists" - backend.SkipDownloadItem(itemID, filename) - } else { - if strings.EqualFold(filepath.Ext(filename), ".flac") && req.CoverURL != "" { - coverClient := backend.NewCoverClient() - if iconErr := coverClient.ApplyMacOSFLACFileIcon(filename, req.CoverURL, 256, req.EmbedMaxQualityCover); iconErr != nil { - fmt.Printf("Warning: failed to set macOS FLAC file icon: %v\n", iconErr) - } else { - fmt.Printf("macOS FLAC file icon set: %s\n", filename) - } - } - - if fileInfo, statErr := os.Stat(filename); statErr == nil { - finalSize := float64(fileInfo.Size()) / (1024 * 1024) - backend.CompleteDownloadItem(itemID, filename, finalSize) - } else { - - backend.CompleteDownloadItem(itemID, filename, 0) - } - - historySource := req.Service - - go func(fPath, track, artist, album, sID, cover, format, source string) { - time.Sleep(2 * time.Second) - - quality := "Unknown" - durationStr := "0:00" - - meta, err := backend.GetTrackMetadata(fPath) - if err == nil { - 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) - durationStr = fmt.Sprintf("%d:%02d", d/60, d%60) - } else { - fmt.Printf("[History] Failed to get metadata for %s: %v\n", fPath, err) - } - - item := backend.HistoryItem{ - SpotifyID: sID, - Title: track, - Artists: artist, - Album: album, - DurationStr: durationStr, - CoverURL: cover, - Quality: quality, - Path: fPath, - Source: source, - } - - item.Format = strings.ToUpper(strings.TrimSpace(format)) - - if ext := filepath.Ext(fPath); len(ext) > 1 { - item.Format = strings.ToUpper(ext[1:]) - } - - switch item.Format { - case "6", "7", "27", "LOSSLESS", "HI_RES", "HI_RES_LOSSLESS": - item.Format = "FLAC" - case "ALAC", "APPLE", "ATMOS", "M4A-AAC", "M4A-ALAC": - item.Format = "M4A" - } - - backend.AddHistoryItem(item, "SpotiFLAC") - }(filename, req.TrackName, req.ArtistName, req.AlbumName, req.SpotifyID, req.CoverURL, req.AudioFormat, historySource) - } - - return DownloadResponse{ - Success: true, - Message: message, - File: filename, - AlreadyExists: alreadyExists, - ItemID: itemID, - }, nil -} - -func (a *App) OpenFolder(path string) error { - if path == "" { - return fmt.Errorf("path is required") - } - - err := backend.OpenFolderInExplorer(path) - if err != nil { - return fmt.Errorf("failed to open folder: %v", err) - } - - return nil -} - -func (a *App) OpenConfigFolder() error { - configDir, err := backend.EnsureAppDir() - if err != nil { - return fmt.Errorf("failed to create config directory: %v", err) - } - return backend.OpenFolderInExplorer(configDir) -} - -func (a *App) SelectFolder(defaultPath string) (string, error) { - return backend.SelectFolderDialog(a.ctx, defaultPath) -} - -func (a *App) SelectFile() (string, error) { - return backend.SelectFileDialog(a.ctx) -} - -func (a *App) GetDefaults() map[string]string { - return map[string]string{ - "downloadPath": backend.GetDefaultMusicPath(), - } -} - -func (a *App) GetDownloadProgress() backend.ProgressInfo { - return backend.GetDownloadProgress() -} - -func (a *App) GetDownloadQueue() backend.DownloadQueueInfo { - return backend.GetDownloadQueue() -} - -func (a *App) ClearCompletedDownloads() { - backend.ClearDownloadQueue() -} - -func (a *App) ClearAllDownloads() { - backend.ClearAllDownloads() -} - -func (a *App) AddToDownloadQueue(spotifyID, trackName, artistName, albumName string) string { - itemID := fmt.Sprintf("%s-%d", spotifyID, time.Now().UnixNano()) - backend.AddToQueue(itemID, trackName, artistName, albumName, "") - return itemID -} - -func (a *App) MarkDownloadItemFailed(itemID, errorMsg string) { - backend.FailDownloadItem(itemID, errorMsg) -} - -func (a *App) CancelAllQueuedItems() { - backend.CancelAllQueuedItems() -} - -func (a *App) ExportFailedDownloads() (string, error) { - queueInfo := backend.GetDownloadQueue() - var failedItems []string - - hasFailed := false - for _, item := range queueInfo.Queue { - if item.Status == backend.StatusFailed { - hasFailed = true - break - } - } - - if !hasFailed { - return "No failed downloads to export.", nil - } - - failedItems = append(failedItems, fmt.Sprintf("Failed Downloads Report - %s", time.Now().Format("2006-01-02 15:04:05"))) - failedItems = append(failedItems, strings.Repeat("-", 50)) - failedItems = append(failedItems, "") - - count := 0 - for _, item := range queueInfo.Queue { - if item.Status == backend.StatusFailed { - count++ - line := fmt.Sprintf("%d. %s - %s", count, item.TrackName, item.ArtistName) - if item.AlbumName != "" { - line += fmt.Sprintf(" (%s)", item.AlbumName) - } - failedItems = append(failedItems, line) - failedItems = append(failedItems, fmt.Sprintf(" Error: %s", item.ErrorMessage)) - - if item.SpotifyID != "" { - failedItems = append(failedItems, fmt.Sprintf(" ID: %s", item.SpotifyID)) - failedItems = append(failedItems, fmt.Sprintf(" URL: https://open.spotify.com/track/%s", item.SpotifyID)) - } - failedItems = append(failedItems, "") - } - } - - content := strings.Join(failedItems, "\n") - defaultFilename := fmt.Sprintf("SpotiFLAC_%s_Failed.txt", time.Now().Format("20060102_150405")) - - path, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{ - DefaultFilename: defaultFilename, - Title: "Export Failed Downloads", - Filters: []runtime.FileFilter{ - { - DisplayName: "Text Files (*.txt)", - Pattern: "*.txt", - }, - }, - }) - - if err != nil { - return "", fmt.Errorf("failed to open save dialog: %v", err) - } - - if path == "" { - return "Export cancelled", nil - } - - if err := os.WriteFile(path, []byte(content), 0644); err != nil { - return "", fmt.Errorf("failed to write file: %v", err) - } - - return fmt.Sprintf("Successfully exported %d failed downloads to %s", count, path), nil -} - -func (a *App) CheckAPIStatus(apiType string, apiURL string) bool { - isOnline, err := runWithTimeout(checkOperationTimeout, func() (bool, error) { - switch apiType { - case "tidal": - return checkGroupedAPIStatus("tidal", buildTidalStatusCheckURLs(apiURL)), nil - case "qobuz", "qbz": - return checkGroupedAPIStatus("qobuz", buildQobuzStatusCheckURLs(apiURL)), nil - case "amazon": - return checkGroupedAPIStatus("amazon", buildAmazonStatusCheckURLs(apiURL)), nil - case "lrclib": - return checkGroupedAPIStatus("lrclib", buildLRCLIBStatusCheckURLs(apiURL)), nil - case "musicbrainz": - return checkGroupedAPIStatus("musicbrainz", buildMusicBrainzStatusCheckURLs(apiURL)), nil - default: - return checkGroupedAPIStatus(apiType, []string{strings.TrimSpace(apiURL)}), nil - } - }) - if err != nil { - if apiType == "musicbrainz" { - backend.SetMusicBrainzStatusCheckResult(false) - } - fmt.Printf("CheckAPIStatus timeout/error for %s (%s): %v\n", apiType, apiURL, err) - return false - } - - if apiType == "musicbrainz" { - backend.SetMusicBrainzStatusCheckResult(isOnline) - } - - return isOnline -} - -func (a *App) CheckAPIStatusReport(apiType string, apiURL string) APIStatusReport { - report, err := runWithTimeout(checkOperationTimeout, func() (APIStatusReport, error) { - switch apiType { - case "tidal": - return buildGroupedAPIStatusReport("tidal", buildTidalStatusCheckURLs(apiURL), false), nil - case "qobuz", "qbz": - return buildGroupedAPIStatusReport("qobuz", buildQobuzStatusCheckURLs(apiURL), false), nil - case "amazon": - return buildGroupedAPIStatusReport("amazon", buildAmazonStatusCheckURLs(apiURL), false), nil - case "lrclib": - return buildGroupedAPIStatusReport("lrclib", buildLRCLIBStatusCheckURLs(apiURL), false), nil - case "musicbrainz": - return buildGroupedAPIStatusReport("musicbrainz", buildMusicBrainzStatusCheckURLs(apiURL), false), nil - default: - return buildGroupedAPIStatusReport(apiType, []string{strings.TrimSpace(apiURL)}, false), nil - } - }) - if err != nil { - return APIStatusReport{ - Type: apiType, - Online: false, - RequireAll: apiType == "qobuz" || apiType == "qbz", - Details: []APIStatusTargetResult{{ - Target: strings.TrimSpace(apiURL), - Label: describeAPIStatusTarget(apiType, apiURL), - Online: false, - Message: err.Error(), - }}, - } - } - return report -} - -func (a *App) CheckCustomTidalAPI(apiURL string) bool { - type tidalProbeResponse struct { - Version string `json:"version"` - Data struct { - TrackID int64 `json:"trackId"` - AssetPresentation string `json:"assetPresentation"` - ManifestMimeType string `json:"manifestMimeType"` - Manifest string `json:"manifest"` - } `json:"data"` - } - type tidalLegacyResponse struct { - OriginalTrackURL string `json:"OriginalTrackUrl"` - } - - apiURL = strings.TrimRight(strings.TrimSpace(apiURL), "/") - if apiURL == "" { - return false - } - - const probeTrackID int64 = 441821360 - probeURL := fmt.Sprintf("%s/track/?id=%d&quality=LOSSLESS", apiURL, probeTrackID) - - req, err := http.NewRequest(http.MethodGet, probeURL, nil) - if err != nil { - fmt.Printf("[CheckCustomTidalAPI] Failed to create request for %s: %v\n", apiURL, err) - return false - } - req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36") - req.Header.Set("Accept", "application/json") - - client := &http.Client{Timeout: 12 * time.Second} - resp, err := client.Do(req) - if err != nil { - fmt.Printf("[CheckCustomTidalAPI] Probe request failed for %s: %v\n", apiURL, err) - return false - } - defer resp.Body.Close() - - body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024)) - if err != nil { - fmt.Printf("[CheckCustomTidalAPI] Failed to read probe response for %s: %v\n", apiURL, err) - return false - } - if resp.StatusCode != http.StatusOK { - fmt.Printf("[CheckCustomTidalAPI] Probe returned status %d for %s: %s\n", resp.StatusCode, apiURL, previewResponseBody(body, 200)) - return false - } - - var probe tidalProbeResponse - if err := json.Unmarshal(body, &probe); err == nil { - assetPresentation := strings.ToUpper(strings.TrimSpace(probe.Data.AssetPresentation)) - switch assetPresentation { - case "FULL": - if strings.TrimSpace(probe.Data.Manifest) != "" { - fmt.Printf("[CheckCustomTidalAPI] Tidal API is ONLINE for %s (assetPresentation=%s)\n", apiURL, assetPresentation) - return true - } - fmt.Printf("[CheckCustomTidalAPI] Probe returned FULL without manifest for %s\n", apiURL) - return false - case "PREVIEW": - fmt.Printf("[CheckCustomTidalAPI] Probe returned PREVIEW for %s\n", apiURL) - return false - case "": - - default: - fmt.Printf("[CheckCustomTidalAPI] Probe returned unsupported assetPresentation=%s for %s\n", assetPresentation, apiURL) - return false - } - } - - var legacy []tidalLegacyResponse - if err := json.Unmarshal(body, &legacy); err == nil { - for _, item := range legacy { - if strings.TrimSpace(item.OriginalTrackURL) != "" { - fmt.Printf("[CheckCustomTidalAPI] Tidal API is ONLINE for %s (legacy response)\n", apiURL) - return true - } - } - } - - fmt.Printf("[CheckCustomTidalAPI] Probe response was unusable for %s: %s\n", apiURL, previewResponseBody(body, 200)) - return false -} - -func buildTidalStatusCheckURLs(apiURL string) []string { - apiURL = strings.TrimRight(strings.TrimSpace(apiURL), "/") - if apiURL == "" { - return nil - } - return []string{fmt.Sprintf("%s/track/?id=441821360&quality=HI_RES_LOSSLESS", apiURL)} -} - -func buildQobuzStatusCheckURLs(apiURL string) []string { - if trimmed := strings.TrimSpace(apiURL); trimmed != "" { - return []string{trimmed} - } - - return backend.GetQobuzDownloadProviderURLs() -} - -func buildAmazonStatusCheckURLs(apiURL string) []string { - baseURL := strings.TrimRight(strings.TrimSpace(apiURL), "/") - if baseURL == "" { - baseURL = backend.GetAmazonMusicAPIBaseURL() - } - return []string{fmt.Sprintf("%s/status", baseURL)} -} - -func buildLRCLIBStatusCheckURLs(apiURL string) []string { - baseURL := strings.TrimRight(strings.TrimSpace(apiURL), "/") - if baseURL == "" { - baseURL = "https://lrclib.net" - } - return []string{fmt.Sprintf("%s/api/search?artist_name=Adele&track_name=Hello", baseURL)} -} - -func buildMusicBrainzStatusCheckURLs(apiURL string) []string { - baseURL := strings.TrimRight(strings.TrimSpace(apiURL), "/") - if baseURL == "" { - baseURL = "https://musicbrainz.org" - } - return []string{fmt.Sprintf("%s/ws/2/recording?query=%s&fmt=json&limit=1", baseURL, url.QueryEscape(`recording:"Hello" AND artist:"Adele"`))} -} - -func checkGroupedAPIStatus(apiType string, checkURLs []string) bool { - filtered := make([]string, 0, len(checkURLs)) - for _, rawURL := range checkURLs { - url := strings.TrimSpace(rawURL) - if url == "" { - continue - } - filtered = append(filtered, url) - } - - if len(filtered) == 0 { - return false - } - - results := make(chan bool, len(filtered)) - var wg sync.WaitGroup - - for _, checkURL := range filtered { - wg.Add(1) - go func(target string) { - defer wg.Done() - results <- checkSingleAPIStatus(apiType, target) - }(checkURL) - } - - go func() { - wg.Wait() - close(results) - }() - - for online := range results { - if online { - return true - } - } - - return false -} - -func buildGroupedAPIStatusReport(apiType string, checkURLs []string, requireAll bool) APIStatusReport { - filtered := make([]string, 0, len(checkURLs)) - for _, rawURL := range checkURLs { - target := strings.TrimSpace(rawURL) - if target == "" { - continue - } - filtered = append(filtered, target) - } - - report := APIStatusReport{ - Type: apiType, - Online: !requireAll, - RequireAll: requireAll, - Details: make([]APIStatusTargetResult, len(filtered)), - } - - if len(filtered) == 0 { - report.Online = false - return report - } - - var wg sync.WaitGroup - for index, target := range filtered { - wg.Add(1) - go func(idx int, rawTarget string) { - defer wg.Done() - report.Details[idx] = checkSingleAPIStatusDetailed(apiType, rawTarget) - }(index, target) - } - wg.Wait() - - if requireAll { - report.Online = true - for _, detail := range report.Details { - if !detail.Online { - report.Online = false - break - } - } - } else { - report.Online = false - for _, detail := range report.Details { - if detail.Online { - report.Online = true - break - } - } - } - - return report -} - -func checkAllGroupedAPIStatus(apiType string, checkURLs []string) bool { - filtered := make([]string, 0, len(checkURLs)) - for _, rawURL := range checkURLs { - url := strings.TrimSpace(rawURL) - if url == "" { - continue - } - filtered = append(filtered, url) - } - - if len(filtered) == 0 { - return false - } - - results := make(chan bool, len(filtered)) - var wg sync.WaitGroup - - for _, checkURL := range filtered { - wg.Add(1) - go func(target string) { - defer wg.Done() - results <- checkSingleAPIStatus(apiType, target) - }(checkURL) - } - - go func() { - wg.Wait() - close(results) - }() - - for online := range results { - if !online { - return false - } - } - - return true -} - -func describeAPIStatusTarget(apiType string, checkURL string) string { - trimmedType := strings.TrimSpace(strings.ToLower(apiType)) - trimmedURL := strings.TrimSpace(checkURL) - - if trimmedType == "qobuz" || trimmedType == "qbz" { - switch { - case backend.IsQobuzWJHEProviderURL(trimmedURL): - return "WJHE" - case backend.IsQobuzMusicDLProviderURL(trimmedURL): - return "MusicDL" - case backend.IsQobuzGDStudioProviderURL(trimmedURL): - parsed, err := url.Parse(trimmedURL) - if err == nil { - host := strings.ToLower(strings.TrimSpace(parsed.Host)) - switch { - case strings.Contains(host, "xyz"): - return "GDStudio XYZ" - case strings.Contains(host, "org"): - return "GDStudio ORG" - } - } - return "GDStudio" - } - } - - if trimmedURL != "" { - if parsed, err := url.Parse(trimmedURL); err == nil && strings.TrimSpace(parsed.Host) != "" { - return strings.TrimSpace(parsed.Host) - } - } - - if trimmedType == "" { - return "Unknown" - } - - return strings.ToUpper(trimmedType) -} - -func checkSingleAPIStatusDetailed(apiType string, checkURL string) APIStatusTargetResult { - result := APIStatusTargetResult{ - Target: strings.TrimSpace(checkURL), - Label: describeAPIStatusTarget(apiType, checkURL), - } - - client := &http.Client{Timeout: 4 * time.Second} - trimmedType := strings.TrimSpace(strings.ToLower(apiType)) - - if trimmedType == "qobuz" || trimmedType == "qbz" { - var err error - switch { - case backend.IsQobuzWJHEProviderURL(checkURL): - err = backend.CheckQobuzWJHEStatusDetailed(client) - case backend.IsQobuzMusicDLProviderURL(checkURL): - err = backend.CheckQobuzMusicDLStatusDetailed(client) - case backend.IsQobuzGDStudioProviderURL(checkURL): - err = backend.CheckQobuzGDStudioAPIStatusDetailed(client, checkURL) - default: - err = fmt.Errorf("unknown qobuz provider url: %s", strings.TrimSpace(checkURL)) - } - - if err != nil { - result.Message = err.Error() - return result - } - - result.Online = true - result.Message = "stream URL resolved" - return result - } - - req, err := backend.NewRequestWithDefaultHeaders(http.MethodGet, checkURL, nil) - if err != nil { - result.Message = fmt.Sprintf("failed to create request: %v", err) - return result - } - - resp, err := client.Do(req) - if err != nil { - result.Message = fmt.Sprintf("request failed: %v", err) - return result - } - defer resp.Body.Close() - - body, err := io.ReadAll(io.LimitReader(resp.Body, 2048)) - if err != nil { - result.Message = fmt.Sprintf("failed to read response: %v", err) - return result - } - - switch trimmedType { - case "amazon": - if resp.StatusCode == http.StatusOK && strings.Contains(string(body), `"amazonMusic":"up"`) { - result.Online = true - result.Message = `amazonMusic="up"` - return result - } - if resp.StatusCode != http.StatusOK { - result.Message = fmt.Sprintf("HTTP %d: %s", resp.StatusCode, previewResponseBody(body, 160)) - return result - } - result.Message = `amazonMusic was not reported as "up"` - return result - default: - if resp.StatusCode == http.StatusOK { - result.Online = true - result.Message = fmt.Sprintf("HTTP %d", resp.StatusCode) - return result - } - result.Message = fmt.Sprintf("HTTP %d: %s", resp.StatusCode, previewResponseBody(body, 160)) - return result - } -} - -func checkSingleAPIStatus(apiType string, checkURL string) bool { - client := &http.Client{Timeout: 4 * time.Second} - if apiType == "qobuz" || apiType == "qbz" { - switch { - case backend.IsQobuzWJHEProviderURL(checkURL): - return backend.CheckQobuzWJHEStatus(client) - case backend.IsQobuzMusicDLProviderURL(checkURL): - return backend.CheckQobuzMusicDLStatus(client) - case backend.IsQobuzGDStudioProviderURL(checkURL): - return backend.CheckQobuzGDStudioAPIStatus(client, checkURL) - } - } - - req, err := backend.NewRequestWithDefaultHeaders(http.MethodGet, checkURL, nil) - if err != nil { - return false - } - - resp, err := client.Do(req) - if err != nil { - return false - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return false - } - - statusCode := resp.StatusCode - switch apiType { - case "amazon": - return statusCode == http.StatusOK && strings.Contains(string(body), `"amazonMusic":"up"`) - case "qobuz", "qbz": - return statusCode == http.StatusOK && containsStreamingURL(body) - case "lrclib": - return statusCode == http.StatusOK && containsLRCLIBResults(body) - case "musicbrainz": - return statusCode == http.StatusOK && containsMusicBrainzResults(body) - default: - return statusCode == http.StatusOK - } -} - -func (a *App) Quit() { - - panic("quit") -} - -func (a *App) GetDownloadHistory() ([]backend.HistoryItem, error) { - return backend.GetHistoryItems("SpotiFLAC") -} - -func (a *App) ClearDownloadHistory() error { - return backend.ClearHistory("SpotiFLAC") -} - -func (a *App) DeleteDownloadHistoryItem(id string) error { - return backend.DeleteHistoryItem(id, "SpotiFLAC") -} - -func (a *App) GetFetchHistory() ([]backend.FetchHistoryItem, error) { - return backend.GetFetchHistoryItems("SpotiFLAC") -} - -func (a *App) AddFetchHistory(item backend.FetchHistoryItem) error { - return backend.AddFetchHistoryItem(item, "SpotiFLAC") -} - -func (a *App) ClearFetchHistory() error { - return backend.ClearFetchHistory("SpotiFLAC") -} - -func (a *App) DeleteFetchHistoryItem(id string) error { - return backend.DeleteFetchHistoryItem(id, "SpotiFLAC") -} - -func (a *App) ClearFetchHistoryByType(itemType string) error { - return backend.ClearFetchHistoryByType(itemType, "SpotiFLAC") -} - -func (a *App) GetRecentFetches() (string, error) { - items, err := backend.LoadRecentFetches() - if err != nil { - return "", err - } - - data, err := json.Marshal(items) - if err != nil { - return "", err - } - - return string(data), nil -} - -func (a *App) SaveRecentFetches(payload string) error { - payload = strings.TrimSpace(payload) - if payload == "" { - payload = "[]" - } - - var items []backend.RecentFetchItem - if err := json.Unmarshal([]byte(payload), &items); err != nil { - return err - } - - return backend.SaveRecentFetches(items) -} - -func (a *App) SaveSpectrumImage(audioFilePath string, base64Data string) (string, error) { - if audioFilePath == "" || base64Data == "" { - return "", fmt.Errorf("file path and image data are required") - } - - base64Data = strings.TrimPrefix(base64Data, "data:image/png;base64,") - - data, err := base64.StdEncoding.DecodeString(base64Data) - if err != nil { - return "", fmt.Errorf("failed to decode base64 image: %v", err) - } - - 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 { - return "", fmt.Errorf("failed to save image to disk: %v", err) - } - - return outPath, nil -} - -type LyricsDownloadRequest struct { - SpotifyID string `json:"spotify_id"` - TrackName string `json:"track_name"` - ArtistName string `json:"artist_name"` - AlbumName string `json:"album_name"` - AlbumArtist string `json:"album_artist"` - ReleaseDate string `json:"release_date"` - ISRC string `json:"isrc,omitempty"` - OutputDir string `json:"output_dir"` - FilenameFormat string `json:"filename_format"` - TrackNumber bool `json:"track_number"` - Position int `json:"position"` - UseAlbumTrackNumber bool `json:"use_album_track_number"` - DiscNumber int `json:"disc_number"` -} - -func (a *App) DownloadLyrics(req LyricsDownloadRequest) (backend.LyricsDownloadResponse, error) { - if req.SpotifyID == "" { - return backend.LyricsDownloadResponse{ - Success: false, - Error: "Spotify ID is required", - }, fmt.Errorf("spotify ID is required") - } - - client := backend.NewLyricsClient() - backendReq := backend.LyricsDownloadRequest{ - SpotifyID: req.SpotifyID, - TrackName: req.TrackName, - ArtistName: req.ArtistName, - AlbumName: req.AlbumName, - AlbumArtist: req.AlbumArtist, - ReleaseDate: req.ReleaseDate, - ISRC: req.ISRC, - OutputDir: req.OutputDir, - FilenameFormat: req.FilenameFormat, - TrackNumber: req.TrackNumber, - Position: req.Position, - UseAlbumTrackNumber: req.UseAlbumTrackNumber, - DiscNumber: req.DiscNumber, - } - - resp, err := client.DownloadLyrics(backendReq) - if err != nil { - return backend.LyricsDownloadResponse{ - Success: false, - Error: err.Error(), - }, err - } - - return *resp, nil -} - -type CoverDownloadRequest struct { - CoverURL string `json:"cover_url"` - TrackName string `json:"track_name"` - ArtistName string `json:"artist_name"` - AlbumName string `json:"album_name"` - AlbumArtist string `json:"album_artist"` - ReleaseDate string `json:"release_date"` - OutputDir string `json:"output_dir"` - FilenameFormat string `json:"filename_format"` - TrackNumber bool `json:"track_number"` - Position int `json:"position"` - DiscNumber int `json:"disc_number"` -} - -func (a *App) DownloadCover(req CoverDownloadRequest) (backend.CoverDownloadResponse, error) { - if req.CoverURL == "" { - return backend.CoverDownloadResponse{ - Success: false, - Error: "Cover URL is required", - }, fmt.Errorf("cover URL is required") - } - - client := backend.NewCoverClient() - backendReq := backend.CoverDownloadRequest{ - CoverURL: req.CoverURL, - TrackName: req.TrackName, - ArtistName: req.ArtistName, - AlbumName: req.AlbumName, - AlbumArtist: req.AlbumArtist, - ReleaseDate: req.ReleaseDate, - OutputDir: req.OutputDir, - FilenameFormat: req.FilenameFormat, - TrackNumber: req.TrackNumber, - Position: req.Position, - DiscNumber: req.DiscNumber, - } - - resp, err := client.DownloadCover(backendReq) - if err != nil { - return backend.CoverDownloadResponse{ - Success: false, - Error: err.Error(), - }, err - } - - return *resp, nil -} - -type HeaderDownloadRequest struct { - HeaderURL string `json:"header_url"` - ArtistName string `json:"artist_name"` - OutputDir string `json:"output_dir"` -} - -func (a *App) DownloadHeader(req HeaderDownloadRequest) (backend.HeaderDownloadResponse, error) { - if req.HeaderURL == "" { - return backend.HeaderDownloadResponse{ - Success: false, - Error: "Header URL is required", - }, fmt.Errorf("header URL is required") - } - - if req.ArtistName == "" { - return backend.HeaderDownloadResponse{ - Success: false, - Error: "Artist name is required", - }, fmt.Errorf("artist name is required") - } - - client := backend.NewCoverClient() - backendReq := backend.HeaderDownloadRequest{ - HeaderURL: req.HeaderURL, - ArtistName: req.ArtistName, - OutputDir: req.OutputDir, - } - - resp, err := client.DownloadHeader(backendReq) - if err != nil { - return backend.HeaderDownloadResponse{ - Success: false, - Error: err.Error(), - }, err - } - - return *resp, nil -} - -type GalleryImageDownloadRequest struct { - ImageURL string `json:"image_url"` - ArtistName string `json:"artist_name"` - ImageIndex int `json:"image_index"` - OutputDir string `json:"output_dir"` -} - -func (a *App) DownloadGalleryImage(req GalleryImageDownloadRequest) (backend.GalleryImageDownloadResponse, error) { - if req.ImageURL == "" { - return backend.GalleryImageDownloadResponse{ - Success: false, - Error: "Image URL is required", - }, fmt.Errorf("image URL is required") - } - - if req.ArtistName == "" { - return backend.GalleryImageDownloadResponse{ - Success: false, - Error: "Artist name is required", - }, fmt.Errorf("artist name is required") - } - - client := backend.NewCoverClient() - backendReq := backend.GalleryImageDownloadRequest{ - ImageURL: req.ImageURL, - ArtistName: req.ArtistName, - ImageIndex: req.ImageIndex, - OutputDir: req.OutputDir, - } - - resp, err := client.DownloadGalleryImage(backendReq) - if err != nil { - return backend.GalleryImageDownloadResponse{ - Success: false, - Error: err.Error(), - }, err - } - - return *resp, nil -} - -type AvatarDownloadRequest struct { - AvatarURL string `json:"avatar_url"` - ArtistName string `json:"artist_name"` - OutputDir string `json:"output_dir"` -} - -func (a *App) DownloadAvatar(req AvatarDownloadRequest) (backend.AvatarDownloadResponse, error) { - if req.AvatarURL == "" { - return backend.AvatarDownloadResponse{ - Success: false, - Error: "Avatar URL is required", - }, fmt.Errorf("avatar URL is required") - } - - if req.ArtistName == "" { - return backend.AvatarDownloadResponse{ - Success: false, - Error: "Artist name is required", - }, fmt.Errorf("artist name is required") - } - - client := backend.NewCoverClient() - backendReq := backend.AvatarDownloadRequest{ - AvatarURL: req.AvatarURL, - ArtistName: req.ArtistName, - OutputDir: req.OutputDir, - } - - resp, err := client.DownloadAvatar(backendReq) - if err != nil { - return backend.AvatarDownloadResponse{ - Success: false, - Error: err.Error(), - }, err - } - - return *resp, nil -} - -func (a *App) CheckTrackAvailability(spotifyTrackID string) (string, error) { - if spotifyTrackID == "" { - return "", fmt.Errorf("spotify track ID is required") - } - - return runWithTimeout(checkOperationTimeout, func() (string, error) { - client := backend.NewSongLinkClient() - availability, err := client.CheckTrackAvailability(spotifyTrackID) - if err != nil { - return "", err - } - - jsonData, err := json.Marshal(availability) - if err != nil { - return "", fmt.Errorf("failed to encode response: %v", err) - } - - return string(jsonData), nil - }) -} - -func (a *App) IsFFmpegInstalled() (bool, error) { - return backend.IsFFmpegInstalled() -} - -func (a *App) IsFFprobeInstalled() (bool, error) { - return backend.IsFFprobeInstalled() -} - -type DownloadFFmpegRequest struct{} - -type DownloadFFmpegResponse struct { - Success bool `json:"success"` - Message string `json:"message"` - Error string `json:"error,omitempty"` -} - -func (a *App) DownloadFFmpeg() DownloadFFmpegResponse { - runtime.EventsEmit(a.ctx, "ffmpeg:status", "starting") - err := backend.DownloadFFmpeg(func(progress int) { - runtime.EventsEmit(a.ctx, "ffmpeg:progress", progress) - }) - if err != nil { - runtime.EventsEmit(a.ctx, "ffmpeg:status", "failed") - return DownloadFFmpegResponse{ - Success: false, - Error: err.Error(), - } - } - - runtime.EventsEmit(a.ctx, "ffmpeg:status", "completed") - return DownloadFFmpegResponse{ - Success: true, - Message: "FFmpeg installed successfully", - } -} - -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 { - InputFiles []string `json:"input_files"` - OutputFormat string `json:"output_format"` - Bitrate string `json:"bitrate"` - Codec string `json:"codec"` -} - -func (a *App) ConvertAudio(req ConvertAudioRequest) ([]backend.ConvertAudioResult, error) { - backendReq := backend.ConvertAudioRequest{ - InputFiles: req.InputFiles, - OutputFormat: req.OutputFormat, - Bitrate: req.Bitrate, - Codec: req.Codec, - } - 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) { - files, err := backend.SelectMultipleFiles(a.ctx) - if err != nil { - return nil, err - } - return files, nil -} - -func (a *App) GetFlacInfoBatch(paths []string) []backend.FlacInfo { - return backend.GetFlacInfoBatch(paths) -} - -func (a *App) GetFileSizes(files []string) map[string]int64 { - return backend.GetFileSizes(files) -} - -func (a *App) ListDirectoryFiles(dirPath string) ([]backend.FileInfo, error) { - if dirPath == "" { - return nil, fmt.Errorf("directory path is required") - } - return backend.ListDirectory(dirPath) -} - -func (a *App) ListAudioFilesInDir(dirPath string) ([]backend.FileInfo, error) { - if dirPath == "" { - return nil, fmt.Errorf("directory path is required") - } - return backend.ListAudioFiles(dirPath) -} - -func (a *App) ReadFileMetadata(filePath string) (*backend.AudioMetadata, error) { - if filePath == "" { - return nil, fmt.Errorf("file path is required") - } - return backend.ReadAudioMetadata(filePath) -} - -func (a *App) PreviewRenameFiles(files []string, format string) []backend.RenamePreview { - return backend.PreviewRename(files, format) -} - -func (a *App) RenameFilesByMetadata(files []string, format string) []backend.RenameResult { - return backend.RenameFiles(files, format) -} - -func (a *App) ReadTextFile(filePath string) (string, error) { - content, err := os.ReadFile(filePath) - if err != nil { - return "", err - } - 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) DecodeAudioForAnalysis(filePath string) (*backend.AnalysisDecodeResponse, error) { - if filePath == "" { - return nil, fmt.Errorf("file path is required") - } - - return backend.DecodeAudioForAnalysis(filePath) -} - -func (a *App) RenameFileTo(oldPath, newName string) error { - dir := filepath.Dir(oldPath) - ext := filepath.Ext(oldPath) - newPath := filepath.Join(dir, newName+ext) - return os.Rename(oldPath, newPath) -} - -func (a *App) SelectImageVideo() ([]string, error) { - return backend.SelectImageVideoDialog(a.ctx) -} - -func (a *App) ReadImageAsBase64(filePath string) (string, error) { - content, err := os.ReadFile(filePath) - if err != nil { - return "", err - } - - ext := strings.ToLower(filepath.Ext(filePath)) - var mimeType string - switch ext { - case ".jpg", ".jpeg": - mimeType = "image/jpeg" - case ".png": - mimeType = "image/png" - case ".gif": - mimeType = "image/gif" - case ".webp": - mimeType = "image/webp" - default: - mimeType = "image/jpeg" - } - - encoded := base64.StdEncoding.EncodeToString(content) - return fmt.Sprintf("data:%s;base64,%s", mimeType, encoded), nil -} - -type CheckFileExistenceRequest struct { - SpotifyID string `json:"spotify_id"` - TrackName string `json:"track_name"` - ArtistName string `json:"artist_name"` - AlbumName string `json:"album_name,omitempty"` - AlbumArtist string `json:"album_artist,omitempty"` - ReleaseDate string `json:"release_date,omitempty"` - ISRC string `json:"isrc,omitempty"` - TrackNumber int `json:"track_number,omitempty"` - DiscNumber int `json:"disc_number,omitempty"` - Position int `json:"position,omitempty"` - UseAlbumTrackNumber bool `json:"use_album_track_number,omitempty"` - FilenameFormat string `json:"filename_format,omitempty"` - IncludeTrackNumber bool `json:"include_track_number,omitempty"` - AudioFormat string `json:"audio_format,omitempty"` - RelativePath string `json:"relative_path,omitempty"` -} - -type CheckFileExistenceResult struct { - SpotifyID string `json:"spotify_id"` - Exists bool `json:"exists"` - FilePath string `json:"file_path,omitempty"` - TrackName string `json:"track_name,omitempty"` - ArtistName string `json:"artist_name,omitempty"` -} - -type existingFileLookupIndex struct { - byFilename map[string]string - byISRC map[string]string -} - -func isAudioFileForExistenceCheck(path string) bool { - switch strings.ToLower(filepath.Ext(path)) { - case ".flac", ".mp3", ".m4a": - return true - default: - return false - } -} - -func normalizeExistingFileIdentifier(value string) string { - return strings.ToUpper(strings.TrimSpace(value)) -} - -func buildExistingFileLookupIndex(scanRoot string, mode string) existingFileLookupIndex { - index := existingFileLookupIndex{ - byFilename: make(map[string]string), - byISRC: make(map[string]string), - } - - scanRoot = backend.NormalizePath(scanRoot) - if scanRoot == "" { - return index - } - - _ = filepath.Walk(scanRoot, func(path string, info os.FileInfo, err error) error { - if err != nil || info == nil || info.IsDir() || !isAudioFileForExistenceCheck(path) { - return nil - } - if info.Size() <= 100*1024 { - return nil - } - - if _, exists := index.byFilename[info.Name()]; !exists { - index.byFilename[info.Name()] = path - } - - if mode == "filename" { - return nil - } - - metadata, metadataErr := backend.ExtractFullMetadataFromFile(path) - if metadataErr != nil { - return nil - } - - if normalizedISRC := normalizeExistingFileIdentifier(metadata.ISRC); normalizedISRC != "" { - if _, exists := index.byISRC[normalizedISRC]; !exists { - index.byISRC[normalizedISRC] = path - } - } - - return nil - }) - - return index -} - -func (a *App) CheckFilesExistence(outputDir string, rootDir string, tracks []CheckFileExistenceRequest) []CheckFileExistenceResult { - if len(tracks) == 0 { - return []CheckFileExistenceResult{} - } - - outputDir = backend.NormalizePath(outputDir) - if rootDir != "" { - rootDir = backend.NormalizePath(rootDir) - } - - defaultFilenameFormat := "title-artist" - redownloadWithSuffix := backend.GetRedownloadWithSuffixSetting() - existingFileCheckMode := backend.GetExistingFileCheckModeSetting() - scanRoot := outputDir - if rootDir != "" { - scanRoot = rootDir - } - - type result struct { - index int - result CheckFileExistenceResult - } - - resultsChan := make(chan result, len(tracks)) - var lookupIndex existingFileLookupIndex - var lookupIndexOnce sync.Once - getLookupIndex := func() existingFileLookupIndex { - lookupIndexOnce.Do(func() { - lookupIndex = buildExistingFileLookupIndex(scanRoot, existingFileCheckMode) - }) - return lookupIndex - } - - for i, track := range tracks { - go func(idx int, t CheckFileExistenceRequest) { - res := CheckFileExistenceResult{ - SpotifyID: t.SpotifyID, - TrackName: t.TrackName, - ArtistName: t.ArtistName, - Exists: false, - } - - if t.TrackName == "" || t.ArtistName == "" { - resultsChan <- result{index: idx, result: res} - return - } - - filenameFormat := t.FilenameFormat - if filenameFormat == "" { - filenameFormat = defaultFilenameFormat - } - isrc := strings.TrimSpace(t.ISRC) - shouldResolveISRC := existingFileCheckMode == "isrc" || strings.Contains(filenameFormat, "{isrc}") - if isrc == "" && shouldResolveISRC && t.SpotifyID != "" { - isrc = backend.ResolveTrackISRC(t.SpotifyID) - } - - trackNumber := t.Position - if t.UseAlbumTrackNumber && t.TrackNumber > 0 { - trackNumber = t.TrackNumber - } - - fileExt := ".flac" - switch strings.ToLower(strings.TrimSpace(t.AudioFormat)) { - case "mp3": - fileExt = ".mp3" - case "m4a", "m4a-aac", "m4a-alac", "alac", "atmos", "apple": - fileExt = ".m4a" - } - - expectedFilenameBase := backend.BuildExpectedFilename( - t.TrackName, - t.ArtistName, - t.AlbumName, - t.AlbumArtist, - t.ReleaseDate, - filenameFormat, - "", - "", - t.IncludeTrackNumber, - trackNumber, - t.DiscNumber, - t.UseAlbumTrackNumber, - isrc, - ) - - expectedFilename := strings.TrimSuffix(expectedFilenameBase, ".flac") + fileExt - - targetDir := outputDir - if t.RelativePath != "" { - targetDir = filepath.Join(outputDir, t.RelativePath) - } - - expectedPath := filepath.Join(targetDir, expectedFilename) - if redownloadWithSuffix { - expectedPath, _ = backend.ResolveOutputPathForDownload(expectedPath, true) - resultsChan <- result{index: idx, result: res} - return - } - - normalizedISRC := normalizeExistingFileIdentifier(isrc) - effectiveMode := existingFileCheckMode - if effectiveMode == "isrc" && normalizedISRC == "" { - effectiveMode = "filename" - } - - switch effectiveMode { - case "isrc": - if path, ok := getLookupIndex().byISRC[normalizedISRC]; ok { - res.Exists = true - res.FilePath = path - } - default: - if fileInfo, err := os.Stat(expectedPath); err == nil && fileInfo.Size() > 100*1024 { - res.Exists = true - res.FilePath = expectedPath - } else if path, ok := getLookupIndex().byFilename[filepath.Base(expectedPath)]; ok { - res.Exists = true - res.FilePath = path - } - } - - resultsChan <- result{index: idx, result: res} - }(i, track) - } - - results := make([]CheckFileExistenceResult, len(tracks)) - - for i := 0; i < len(tracks); i++ { - r := <-resultsChan - results[r.index] = r.result - } - - return results -} - -func (a *App) SkipDownloadItem(itemID, filePath string) { - backend.SkipDownloadItem(itemID, filePath) -} - -func (a *App) GetTrackISRC(spotifyTrackID string) string { - return backend.ResolveTrackISRC(spotifyTrackID) -} - -func (a *App) GetPreviewURL(trackID string) (string, error) { - return backend.GetPreviewURL(trackID) -} - -func (a *App) GetConfigPath() (string, error) { - dir, err := backend.GetFFmpegDir() - if err != nil { - return "", err - } - return filepath.Join(dir, "config.json"), nil -} - -func (a *App) GetFontsPath() (string, error) { - dir, err := backend.GetFFmpegDir() - if err != nil { - return "", err - } - return filepath.Join(dir, "fonts.json"), nil -} - -func (a *App) SaveSettings(settings map[string]interface{}) error { - configPath, err := a.GetConfigPath() - if err != nil { - return err - } - settings = backend.SanitizeSettingsMap(settings) - - dir := filepath.Dir(configPath) - if _, err := os.Stat(dir); os.IsNotExist(err) { - if err := os.MkdirAll(dir, 0755); err != nil { - return err - } - } - - data, err := json.MarshalIndent(settings, "", " ") - if err != nil { - return err - } - - return os.WriteFile(configPath, data, 0644) -} - -func (a *App) SaveFonts(fonts []map[string]interface{}) error { - fontsPath, err := a.GetFontsPath() - if err != nil { - return err - } - - dir := filepath.Dir(fontsPath) - if _, err := os.Stat(dir); os.IsNotExist(err) { - if err := os.MkdirAll(dir, 0755); err != nil { - return err - } - } - - data, err := json.MarshalIndent(fonts, "", " ") - if err != nil { - return err - } - - return os.WriteFile(fontsPath, data, 0644) -} - -func (a *App) LoadSettings() (map[string]interface{}, error) { - configPath, err := a.GetConfigPath() - if err != nil { - return nil, err - } - - if _, err := os.Stat(configPath); os.IsNotExist(err) { - return nil, nil - } - - data, err := os.ReadFile(configPath) - if err != nil { - return nil, err - } - - var settings map[string]interface{} - if err := json.Unmarshal(data, &settings); err != nil { - return nil, err - } - - return backend.SanitizeSettingsMap(settings), nil -} - -func (a *App) LoadFonts() ([]map[string]interface{}, error) { - fontsPath, err := a.GetFontsPath() - if err != nil { - return nil, err - } - - if _, err := os.Stat(fontsPath); os.IsNotExist(err) { - return nil, nil - } - - data, err := os.ReadFile(fontsPath) - if err != nil { - return nil, err - } - - var fonts []map[string]interface{} - if err := json.Unmarshal(data, &fonts); err != nil { - return nil, err - } - if fonts == nil { - return []map[string]interface{}{}, nil - } - - return fonts, nil -} - -func (a *App) CheckFFmpegInstalled() (bool, error) { - return backend.IsFFmpegInstalled() -} - -func (a *App) CreateM3U8File(m3u8Name string, outputDir string, filePaths []string) error { - if len(filePaths) == 0 { - return nil - } - - if err := os.MkdirAll(outputDir, 0755); err != nil { - return err - } - - fnName := m3u8Name - - safeName := backend.SanitizeFilename(fnName) - if safeName == "" { - safeName = "playlist" - } - - m3u8Path := filepath.Join(outputDir, safeName+".m3u8") - - f, err := os.Create(m3u8Path) - if err != nil { - return err - } - defer f.Close() - - if _, err := f.WriteString("#EXTM3U\n"); err != nil { - return err - } - - for _, path := range filePaths { - if path == "" { - continue - } - - relPath, err := filepath.Rel(outputDir, path) - if err != nil { - - relPath = path - } - - relPath = filepath.ToSlash(relPath) - - if _, err := f.WriteString(relPath + "\n"); err != nil { - return err - } - } - - return nil -} diff --git a/backend/amazon.go b/backend/amazon.go deleted file mode 100644 index 207bd41..0000000 --- a/backend/amazon.go +++ /dev/null @@ -1,537 +0,0 @@ -package backend - -import ( - "crypto/aes" - "crypto/cipher" - "crypto/sha256" - "encoding/json" - "fmt" - "io" - "net/http" - "os" - "os/exec" - "path/filepath" - "regexp" - "strings" - "sync" - "time" -) - -type AmazonDownloader struct { - client *http.Client - regions []string -} - -type AmazonStreamResponse struct { - StreamURL string `json:"streamUrl"` - DecryptionKey string `json:"decryptionKey"` -} - -var ( - amazonMusicDebugKeyOnce sync.Once - amazonMusicDebugKey string - amazonMusicDebugKeyErr error -) - -var amazonMusicDebugKeySeedParts = [][]byte{ - []byte("spotif"), - []byte("lac:am"), - []byte("azon:spotbye:api:v1"), -} - -var amazonMusicDebugKeyAAD = []byte{ - 0x61, 0x6d, 0x61, 0x7a, 0x6f, 0x6e, 0x7c, 0x73, 0x70, 0x6f, 0x74, 0x62, - 0x79, 0x65, 0x7c, 0x64, 0x65, 0x62, 0x75, 0x67, 0x7c, 0x76, 0x31, -} - -var amazonMusicDebugKeyNonce = []byte{ - 0x52, 0x1f, 0xa4, 0x9c, 0x13, 0x77, 0x5b, 0xe2, 0x81, 0x44, 0x90, 0x6d, -} - -var amazonMusicDebugKeyCiphertext = []byte{ - 0x5b, 0xf9, 0xc1, 0x2e, 0x58, 0xf8, 0x5b, 0xc0, 0x04, 0x68, 0x7e, 0xff, - 0x3d, 0xd6, 0x8b, 0xe3, 0x86, 0x49, 0x6c, 0xfd, 0xc1, 0x49, 0x0b, 0xfb, -} - -var amazonMusicDebugKeyTag = []byte{ - 0x6c, 0x21, 0x98, 0x51, 0xf2, 0x38, 0x4b, 0x4a, 0x23, 0xe1, 0xc6, 0xd7, - 0x65, 0x7f, 0xfb, 0xa1, -} - -func getAmazonMusicDebugKey() (string, error) { - amazonMusicDebugKeyOnce.Do(func() { - hasher := sha256.New() - for _, part := range amazonMusicDebugKeySeedParts { - hasher.Write(part) - } - - block, err := aes.NewCipher(hasher.Sum(nil)) - if err != nil { - amazonMusicDebugKeyErr = err - return - } - - gcm, err := cipher.NewGCM(block) - if err != nil { - amazonMusicDebugKeyErr = err - return - } - - sealed := make([]byte, 0, len(amazonMusicDebugKeyCiphertext)+len(amazonMusicDebugKeyTag)) - sealed = append(sealed, amazonMusicDebugKeyCiphertext...) - sealed = append(sealed, amazonMusicDebugKeyTag...) - - plaintext, err := gcm.Open(nil, amazonMusicDebugKeyNonce, sealed, amazonMusicDebugKeyAAD) - if err != nil { - amazonMusicDebugKeyErr = err - return - } - - amazonMusicDebugKey = string(plaintext) - }) - - if amazonMusicDebugKeyErr != nil { - return "", amazonMusicDebugKeyErr - } - - return amazonMusicDebugKey, nil -} - -func NewAmazonDownloader() *AmazonDownloader { - return &AmazonDownloader{ - client: &http.Client{ - Timeout: 120 * time.Second, - }, - regions: []string{"us", "eu"}, - } -} - -func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (string, error) { - fmt.Println("Getting Amazon URL...") - client := NewSongLinkClient() - urls, err := client.GetAllURLsFromSpotify(spotifyTrackID, "") - if err != nil { - return "", fmt.Errorf("failed to get Amazon URL: %w", err) - } - - amazonURL := normalizeAmazonMusicURL(urls.AmazonURL) - if amazonURL == "" { - return "", fmt.Errorf("amazon Music link not found") - } - fmt.Printf("Found Amazon URL: %s\n", amazonURL) - return amazonURL, nil -} - -func (a *AmazonDownloader) DownloadFromAfkarXYZ(amazonURL, outputDir, quality string) (string, error) { - - asinRegex := regexp.MustCompile(`(B[0-9A-Z]{9})`) - asin := asinRegex.FindString(amazonURL) - if asin == "" { - return "", fmt.Errorf("failed to extract ASIN from URL: %s", amazonURL) - } - - apiURL := fmt.Sprintf("%s/api/track/%s", amazonMusicAPIBaseURL, asin) - req, err := NewRequestWithDefaultHeaders(http.MethodGet, apiURL, nil) - if err != nil { - return "", err - } - - debugKey, err := getAmazonMusicDebugKey() - if err != nil { - return "", fmt.Errorf("failed to decrypt Amazon debug key: %w", err) - } - req.Header.Set("X-Debug-Key", debugKey) - - fmt.Printf("Fetching from Amazon API (ASIN: %s)...\n", asin) - resp, err := a.client.Do(req) - if err != nil { - return "", err - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - return "", fmt.Errorf("Amazon API returned status %d", resp.StatusCode) - } - - bodyBytes, err := io.ReadAll(resp.Body) - if err != nil { - return "", err - } - - var apiResp AmazonStreamResponse - if err := json.Unmarshal(bodyBytes, &apiResp); err != nil { - return "", fmt.Errorf("failed to decode response: %w", err) - } - - if apiResp.StreamURL == "" { - return "", fmt.Errorf("no stream URL found in response") - } - - downloadURL := apiResp.StreamURL - fileName := fmt.Sprintf("%s.m4a", asin) - filePath := filepath.Join(outputDir, fileName) - - out, err := os.Create(filePath) - if err != nil { - return "", err - } - defer out.Close() - - dlReq, err := NewRequestWithDefaultHeaders(http.MethodGet, downloadURL, nil) - if err != nil { - return "", err - } - - dlResp, err := a.client.Do(dlReq) - if err != nil { - return "", err - } - defer dlResp.Body.Close() - - fmt.Printf("Downloading track: %s\n", fileName) - pw := NewProgressWriter(out) - _, err = io.Copy(pw, dlResp.Body) - if err != nil { - out.Close() - os.Remove(filePath) - return "", err - } - - fmt.Printf("\rDownloaded: %.2f MB (Complete)\n", float64(pw.GetTotal())/(1024*1024)) - - if apiResp.DecryptionKey != "" { - fmt.Printf("Decrypting file...\n") - - ffprobePath, err := GetFFprobePath() - var codec string - if err == nil { - cmdProbe := exec.Command(ffprobePath, - "-v", "quiet", - "-select_streams", "a:0", - "-show_entries", "stream=codec_name", - "-of", "default=noprint_wrappers=1:nokey=1", - filePath, - ) - setHideWindow(cmdProbe) - codecOutput, _ := cmdProbe.Output() - codec = strings.TrimSpace(string(codecOutput)) - fmt.Printf("Detected codec: %s\n", codec) - } - - targetExt := ".m4a" - if codec == "flac" { - targetExt = ".flac" - } - - decryptedFilename := "dec_" + fileName + targetExt - - if targetExt == ".flac" && strings.HasSuffix(fileName, ".m4a") { - decryptedFilename = "dec_" + strings.TrimSuffix(fileName, ".m4a") + ".flac" - } - - decryptedPath := filepath.Join(outputDir, decryptedFilename) - - ffmpegPath, err := GetFFmpegPath() - if err != nil { - return "", fmt.Errorf("ffmpeg not found for decryption: %w", err) - } - - if err := ValidateExecutable(ffmpegPath); err != nil { - return "", fmt.Errorf("invalid ffmpeg executable: %w", err) - } - - key := strings.TrimSpace(apiResp.DecryptionKey) - - cmd := exec.Command(ffmpegPath, - "-decryption_key", key, - "-i", filePath, - "-c", "copy", - "-y", - decryptedPath, - ) - - setHideWindow(cmd) - output, err := cmd.CombinedOutput() - if err != nil { - - outStr := string(output) - if len(outStr) > 500 { - outStr = outStr[len(outStr)-500:] - } - return "", fmt.Errorf("ffmpeg decryption failed: %v\nTail Output: %s", err, outStr) - } - - if info, err := os.Stat(decryptedPath); err != nil || info.Size() == 0 { - return "", fmt.Errorf("decrypted file missing or empty") - } - - if err := os.Remove(filePath); err != nil { - fmt.Printf("Warning: Failed to remove encrypted file: %v\n", err) - } - - finalPath := filepath.Join(outputDir, strings.TrimPrefix(decryptedFilename, "dec_")) - if err := os.Rename(decryptedPath, finalPath); err != nil { - return "", fmt.Errorf("failed to rename decrypted file: %w", err) - } - filePath = finalPath - - fmt.Println("Decryption successful") - } - - return filePath, nil -} - -func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir, quality string) (string, error) { - 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, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL string, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) { - - if outputDir != "." { - if err := os.MkdirAll(outputDir, 0755); err != nil { - return "", fmt.Errorf("failed to create output directory: %w", err) - } - } - - if spotifyTrackName != "" && spotifyArtistName != "" { - 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, isrcOverride) - expectedPath := filepath.Join(outputDir, expectedFilename) - - if !GetRedownloadWithSuffixSetting() { - if fileInfo, err := os.Stat(expectedPath); err == nil && fileInfo.Size() > 0 { - fmt.Printf("File already exists: %s (%.2f MB)\n", expectedPath, float64(fileInfo.Size())/(1024*1024)) - return "EXISTS:" + expectedPath, nil - } - } - } - - 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 != "" { - if ShouldSkipMusicBrainzMetadataFetch() { - fmt.Println("Skipping MusicBrainz metadata fetch because status check is offline.") - } else { - 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) - - filePath, err := a.DownloadFromService(amazonURL, outputDir, quality) - if err != nil { - return "", err - } - - isrc := strings.TrimSpace(isrcOverride) - var mbMeta Metadata - if spotifyURL != "" { - result := <-metaChan - if isrc == "" { - isrc = result.ISRC - } - mbMeta = result.Metadata - } - - upc := "" - if spotifyURL != "" { - if identifiers, err := GetSpotifyTrackIdentifiersDirect(spotifyURL); err == nil || identifiers.ISRC != "" || identifiers.UPC != "" { - if strings.TrimSpace(isrc) == "" && strings.TrimSpace(identifiers.ISRC) != "" { - isrc = strings.TrimSpace(identifiers.ISRC) - } - upc = strings.TrimSpace(identifiers.UPC) - } - } - - originalFileDir := filepath.Dir(filePath) - originalFileBase := strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath)) - - if spotifyTrackName != "" && spotifyArtistName != "" { - safeArtist := sanitizeFilename(spotifyArtistName) - safeAlbumArtist := sanitizeFilename(spotifyAlbumArtist) - - if useFirstArtistOnly { - safeArtist = sanitizeFilename(GetFirstArtist(spotifyArtistName)) - safeAlbumArtist = sanitizeFilename(GetFirstArtist(spotifyAlbumArtist)) - } - - safeTitle := sanitizeFilename(spotifyTrackName) - safeAlbum := sanitizeFilename(spotifyAlbumName) - - year := "" - if len(spotifyReleaseDate) >= 4 { - year = spotifyReleaseDate[:4] - } - - var newFilename string - - if strings.Contains(filenameFormat, "{") { - newFilename = filenameFormat - newFilename = strings.ReplaceAll(newFilename, "{title}", safeTitle) - newFilename = strings.ReplaceAll(newFilename, "{artist}", safeArtist) - newFilename = strings.ReplaceAll(newFilename, "{album}", safeAlbum) - newFilename = strings.ReplaceAll(newFilename, "{album_artist}", safeAlbumArtist) - newFilename = strings.ReplaceAll(newFilename, "{year}", year) - newFilename = strings.ReplaceAll(newFilename, "{date}", SanitizeFilename(spotifyReleaseDate)) - newFilename = strings.ReplaceAll(newFilename, "{isrc}", SanitizeOptionalFilename(isrc)) - - if spotifyDiscNumber > 0 { - newFilename = strings.ReplaceAll(newFilename, "{disc}", fmt.Sprintf("%d", spotifyDiscNumber)) - } else { - newFilename = strings.ReplaceAll(newFilename, "{disc}", "") - } - - if position > 0 { - newFilename = strings.ReplaceAll(newFilename, "{track}", fmt.Sprintf("%02d", position)) - } else { - - newFilename = regexp.MustCompile(`\{track\}\.\s*`).ReplaceAllString(newFilename, "") - newFilename = regexp.MustCompile(`\{track\}\s*-\s*`).ReplaceAllString(newFilename, "") - newFilename = regexp.MustCompile(`\{track\}\s*`).ReplaceAllString(newFilename, "") - } - } else { - - switch filenameFormat { - case "artist-title": - newFilename = fmt.Sprintf("%s - %s", safeArtist, safeTitle) - case "title": - newFilename = safeTitle - default: - newFilename = fmt.Sprintf("%s - %s", safeTitle, safeArtist) - } - - if includeTrackNumber && position > 0 { - newFilename = fmt.Sprintf("%02d. %s", position, newFilename) - } - } - - ext := filepath.Ext(filePath) - if ext == "" { - ext = ".flac" - } - newFilename = newFilename + ext - newFilePath := filepath.Join(outputDir, newFilename) - if GetRedownloadWithSuffixSetting() { - newFilePath, _ = ResolveOutputPathForDownload(newFilePath, true) - } - - if err := os.Rename(filePath, newFilePath); err != nil { - fmt.Printf("Warning: Failed to rename file: %v\n", err) - } else { - filePath = newFilePath - fmt.Printf("Renamed to: %s\n", newFilename) - } - } - - fmt.Println("Embedding Spotify metadata...") - - coverPath := "" - - if spotifyCoverURL != "" { - coverPath = filePath + ".cover.jpg" - coverClient := NewCoverClient() - if err := coverClient.DownloadCoverToPath(spotifyCoverURL, coverPath, embedMaxQualityCover); err != nil { - fmt.Printf("Warning: Failed to download Spotify cover: %v\n", err) - coverPath = "" - } else { - defer os.Remove(coverPath) - fmt.Println("Spotify cover downloaded") - } - } - - trackNumberToEmbed := spotifyTrackNumber - if trackNumberToEmbed == 0 { - trackNumberToEmbed = 1 - } - - metadata := Metadata{ - Title: spotifyTrackName, - Artist: spotifyArtistName, - Album: spotifyAlbumName, - AlbumArtist: spotifyAlbumArtist, - Date: spotifyReleaseDate, - TrackNumber: trackNumberToEmbed, - TotalTracks: spotifyTotalTracks, - DiscNumber: spotifyDiscNumber, - TotalDiscs: spotifyTotalDiscs, - URL: spotifyURL, - Comment: spotifyURL, - Copyright: spotifyCopyright, - Publisher: spotifyPublisher, - Composer: spotifyComposer, - Separator: metadataSeparator, - Description: "https://github.com/spotbye/SpotiFLAC", - ISRC: isrc, - UPC: upc, - Genre: mbMeta.Genre, - } - - if err := EmbedMetadataToConvertedFile(filePath, metadata, coverPath); err != nil { - fmt.Printf("Warning: Failed to embed metadata: %v\n", err) - } else { - fmt.Println("Metadata embedded successfully") - } - - if strings.HasSuffix(strings.ToLower(filePath), ".flac") { - - originalM4aPath := filepath.Join(originalFileDir, originalFileBase+".m4a") - if _, err := os.Stat(originalM4aPath); err == nil { - if err := os.Remove(originalM4aPath); err != nil { - fmt.Printf("Warning: Failed to remove M4A file: %v\n", err) - } else { - fmt.Printf("Cleaned up original M4A file: %s\n", filepath.Base(originalM4aPath)) - } - } - } - - fmt.Println("Done") - fmt.Println("✓ Downloaded successfully from Amazon Music") - 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, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL string, - useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool, -) (string, error) { - - amazonURL, err := a.GetAmazonURLFromSpotify(spotifyTrackID) - if err != nil { - 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, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL, useFirstArtistOnly, useSingleGenre, embedGenre) -} diff --git a/backend/analysis.go b/backend/analysis.go deleted file mode 100644 index 2753899..0000000 --- a/backend/analysis.go +++ /dev/null @@ -1,214 +0,0 @@ -package backend - -import ( - "bytes" - "encoding/base64" - "fmt" - "os" - "os/exec" - "strconv" - "strings" - "time" -) - -type AnalysisResult struct { - FilePath string `json:"file_path"` - FileSize int64 `json:"file_size"` - SampleRate uint32 `json:"sample_rate"` - Channels uint8 `json:"channels"` - BitsPerSample uint8 `json:"bits_per_sample"` - TotalSamples uint64 `json:"total_samples"` - Duration float64 `json:"duration"` - Bitrate int `json:"bit_rate"` - BitDepth string `json:"bit_depth"` - DynamicRange float64 `json:"dynamic_range"` - PeakAmplitude float64 `json:"peak_amplitude"` - RMSLevel float64 `json:"rms_level"` -} - -type AnalysisDecodeResponse struct { - PCMBase64 string `json:"pcm_base64"` - SampleRate uint32 `json:"sample_rate"` - Channels uint8 `json:"channels"` - BitsPerSample uint8 `json:"bits_per_sample"` - Duration float64 `json:"duration"` - BitrateKbps int `json:"bitrate_kbps,omitempty"` - BitDepth string `json:"bit_depth,omitempty"` -} - -func GetTrackMetadata(filepath string) (*AnalysisResult, error) { - if !fileExists(filepath) { - return nil, fmt.Errorf("file does not exist: %s", filepath) - } - - return GetMetadataWithFFprobe(filepath) -} - -func GetMetadataWithFFprobe(filePath string) (*AnalysisResult, error) { - ffprobePath, err := GetFFprobePath() - if err != nil { - return nil, err - } - - 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 { - return nil, fmt.Errorf("ffprobe failed: %v - %s", err, string(output)) - } - - infoMap := make(map[string]string) - lines := strings.Split(string(output), "\n") - for _, line := range lines { - if strings.Contains(line, "=") { - parts := strings.SplitN(line, "=", 2) - infoMap[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1]) - } - } - - 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 -} - -func DecodeAudioForAnalysis(filePath string) (*AnalysisDecodeResponse, error) { - metadata, err := GetTrackMetadata(filePath) - if err != nil { - return nil, err - } - - pcmBase64, err := extractAnalysisPCMBase64(filePath) - if err != nil { - return nil, err - } - - resp := &AnalysisDecodeResponse{ - PCMBase64: pcmBase64, - SampleRate: metadata.SampleRate, - Channels: metadata.Channels, - BitsPerSample: metadata.BitsPerSample, - Duration: metadata.Duration, - BitDepth: metadata.BitDepth, - } - - if metadata.Bitrate > 0 { - resp.BitrateKbps = metadata.Bitrate / 1000 - } - - return resp, nil -} - -func extractAnalysisPCMBase64(filePath string) (string, error) { - ffmpegPath, err := GetFFmpegPath() - if err != nil { - return "", err - } - - argSets := [][]string{ - { - "-v", "error", - "-i", filePath, - "-vn", - "-map", "0:a:0", - "-af", "pan=mono|c0=c0", - "-f", "s16le", - "-acodec", "pcm_s16le", - "pipe:1", - }, - { - "-v", "error", - "-i", filePath, - "-vn", - "-map", "0:a:0", - "-ac", "1", - "-f", "s16le", - "-acodec", "pcm_s16le", - "pipe:1", - }, - } - - var lastErr error - - for _, args := range argSets { - var stdout bytes.Buffer - var stderr bytes.Buffer - - cmd := exec.Command(ffmpegPath, args...) - setHideWindow(cmd) - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - if err := cmd.Run(); err != nil { - lastErr = fmt.Errorf("ffmpeg analysis decode failed: %w - %s", err, strings.TrimSpace(stderr.String())) - continue - } - - if stdout.Len() == 0 { - lastErr = fmt.Errorf("ffmpeg analysis decode returned empty PCM output") - continue - } - - return base64.StdEncoding.EncodeToString(stdout.Bytes()), nil - } - - if lastErr != nil { - return "", lastErr - } - - return "", fmt.Errorf("ffmpeg analysis decode failed") -} diff --git a/backend/artist_format.go b/backend/artist_format.go deleted file mode 100644 index 29c4f26..0000000 --- a/backend/artist_format.go +++ /dev/null @@ -1,90 +0,0 @@ -package backend - -import "strings" - -func normalizeArtistSeparator(separator string) string { - separator = strings.TrimSpace(separator) - if separator == "," || separator == ";" { - return separator - } - return "" -} - -func splitArtistSegment(segment string, separator string) []string { - segment = strings.TrimSpace(segment) - if segment == "" { - return nil - } - - if strings.Contains(segment, "|||SEP|||") { - return strings.Split(segment, "|||SEP|||") - } - - parts := []string{segment} - - if separator = normalizeArtistSeparator(separator); separator != "" { - var separated []string - for _, part := range parts { - for _, item := range strings.Split(part, separator) { - separated = append(separated, item) - } - } - parts = separated - } else if strings.Contains(segment, ";") { - var separated []string - for _, part := range parts { - for _, item := range strings.Split(part, ";") { - separated = append(separated, item) - } - } - parts = separated - } - - return parts -} - -func SplitArtistCredits(artistStr, separator string) []string { - rawParts := splitArtistSegment(artistStr, separator) - if len(rawParts) == 0 { - return nil - } - - seen := make(map[string]struct{}, len(rawParts)) - result := make([]string, 0, len(rawParts)) - for _, part := range rawParts { - part = strings.TrimSpace(part) - if part == "" { - continue - } - if _, exists := seen[part]; exists { - continue - } - seen[part] = struct{}{} - result = append(result, part) - } - - return result -} - -func SplitMetadataValues(value, separator string) []string { - rawParts := splitArtistSegment(value, separator) - if len(rawParts) == 0 { - return nil - } - - seen := make(map[string]struct{}, len(rawParts)) - result := make([]string, 0, len(rawParts)) - for _, part := range rawParts { - part = strings.TrimSpace(part) - if part == "" { - continue - } - if _, exists := seen[part]; exists { - continue - } - seen[part] = struct{}{} - result = append(result, part) - } - - return result -} diff --git a/backend/config.go b/backend/config.go deleted file mode 100644 index 7da1973..0000000 --- a/backend/config.go +++ /dev/null @@ -1,249 +0,0 @@ -package backend - -import ( - "encoding/json" - "errors" - "os" - "path/filepath" - "strings" -) - -const legacyTidalAPICacheFile = "tidal-api-urls.json" - -func normalizeCustomTidalAPIValue(value interface{}) string { - customAPI, _ := value.(string) - customAPI = strings.TrimRight(strings.TrimSpace(customAPI), "/") - if strings.HasPrefix(customAPI, "https://") { - return customAPI - } - return "" -} - -func sanitizeDownloaderValue(value interface{}, allowTidal bool) string { - downloader, _ := value.(string) - switch strings.TrimSpace(strings.ToLower(downloader)) { - case "tidal": - if allowTidal { - return "tidal" - } - return "auto" - case "qobuz": - return "qobuz" - case "amazon": - return "amazon" - default: - return "auto" - } -} - -func sanitizeAutoOrderValue(value interface{}, allowTidal bool) string { - autoOrder, _ := value.(string) - allowed := map[string]struct{}{ - "qobuz": {}, - "amazon": {}, - } - fallback := "qobuz-amazon" - if allowTidal { - allowed["tidal"] = struct{}{} - fallback = "tidal-qobuz-amazon" - } - - seen := make(map[string]struct{}) - parts := make([]string, 0, 3) - for _, rawPart := range strings.Split(strings.TrimSpace(strings.ToLower(autoOrder)), "-") { - part := strings.TrimSpace(rawPart) - if part == "" { - continue - } - if _, ok := allowed[part]; !ok { - continue - } - if _, ok := seen[part]; ok { - continue - } - seen[part] = struct{}{} - parts = append(parts, part) - } - - if len(parts) < 2 { - return fallback - } - - return strings.Join(parts, "-") -} - -func SanitizeSettingsMap(settings map[string]interface{}) map[string]interface{} { - if settings == nil { - return nil - } - - sanitized := make(map[string]interface{}, len(settings)) - for key, value := range settings { - sanitized[key] = value - } - - customAPI := normalizeCustomTidalAPIValue(sanitized["customTidalApi"]) - sanitized["customTidalApi"] = customAPI - allowTidal := customAPI != "" - sanitized["downloader"] = sanitizeDownloaderValue(sanitized["downloader"], allowTidal) - sanitized["autoOrder"] = sanitizeAutoOrderValue(sanitized["autoOrder"], allowTidal) - - return sanitized -} - -func CleanupLegacyTidalPublicAPIState() error { - appDir, err := EnsureAppDir() - if err != nil { - return err - } - - cachePath := filepath.Join(appDir, legacyTidalAPICacheFile) - if err := os.Remove(cachePath); err != nil && !errors.Is(err, os.ErrNotExist) { - return err - } - - return nil -} - -func SanitizePersistedConfigSettings() error { - configPath, err := GetConfigPath() - if err != nil { - return err - } - - if _, err := os.Stat(configPath); os.IsNotExist(err) { - return nil - } - - data, err := os.ReadFile(configPath) - if err != nil { - return err - } - - var settings map[string]interface{} - if err := json.Unmarshal(data, &settings); err != nil { - return err - } - - sanitized := SanitizeSettingsMap(settings) - payload, err := json.MarshalIndent(sanitized, "", " ") - if err != nil { - return err - } - - return os.WriteFile(configPath, payload, 0o644) -} - -func GetDefaultMusicPath() string { - - homeDir, err := os.UserHomeDir() - if err != nil { - - return "C:\\Users\\Public\\Music" - } - - return filepath.Join(homeDir, "Music") -} - -func GetConfigPath() (string, error) { - dir, err := EnsureAppDir() - if err != nil { - return "", err - } - - return filepath.Join(dir, "config.json"), nil -} - -func LoadConfigSettings() (map[string]interface{}, error) { - configPath, err := GetConfigPath() - if err != nil { - return nil, err - } - - if _, err := os.Stat(configPath); os.IsNotExist(err) { - return nil, nil - } - - data, err := os.ReadFile(configPath) - if err != nil { - return nil, err - } - - var settings map[string]interface{} - if err := json.Unmarshal(data, &settings); err != nil { - return nil, err - } - - return SanitizeSettingsMap(settings), nil -} - -func GetRedownloadWithSuffixSetting() bool { - settings, err := LoadConfigSettings() - if err != nil || settings == nil { - return false - } - - enabled, _ := settings["redownloadWithSuffix"].(bool) - return enabled -} - -func GetCustomTidalAPISetting() string { - settings, err := LoadConfigSettings() - if err != nil || settings == nil { - return "" - } - - return normalizeCustomTidalAPIValue(settings["customTidalApi"]) -} - -func normalizeExistingFileCheckMode(value string) string { - switch strings.TrimSpace(strings.ToLower(value)) { - case "isrc", "upc": - return "isrc" - default: - return "filename" - } -} - -func GetExistingFileCheckModeSetting() string { - settings, err := LoadConfigSettings() - if err != nil || settings == nil { - return "filename" - } - - rawMode, _ := settings["existingFileCheckMode"].(string) - return normalizeExistingFileCheckMode(rawMode) -} - -func GetLinkResolverSetting() string { - settings, err := LoadConfigSettings() - if err != nil || settings == nil { - return linkResolverProviderDeezerSongLink - } - - resolver, _ := settings["linkResolver"].(string) - switch strings.TrimSpace(strings.ToLower(resolver)) { - case "songlink", linkResolverProviderDeezerSongLink: - return linkResolverProviderDeezerSongLink - case "songstats": - return linkResolverProviderSongstats - case "": - return linkResolverProviderDeezerSongLink - default: - return linkResolverProviderDeezerSongLink - } -} - -func GetLinkResolverAllowFallback() bool { - settings, err := LoadConfigSettings() - if err != nil || settings == nil { - return true - } - - allowFallback, ok := settings["allowResolverFallback"].(bool) - if !ok { - return true - } - - return allowFallback -} diff --git a/backend/cover.go b/backend/cover.go deleted file mode 100644 index e40256a..0000000 --- a/backend/cover.go +++ /dev/null @@ -1,595 +0,0 @@ -package backend - -import ( - "bytes" - "fmt" - "image" - "image/png" - "io" - "net/http" - "os" - "path/filepath" - "regexp" - "strings" - "time" - - xdraw "golang.org/x/image/draw" - _ "image/jpeg" -) - -const ( - spotifySize300 = "ab67616d00001e02" - spotifySize640 = "ab67616d0000b273" - spotifySizeMax = "ab67616d000082c1" -) - -type CoverDownloadRequest struct { - CoverURL string `json:"cover_url"` - TrackName string `json:"track_name"` - ArtistName string `json:"artist_name"` - AlbumName string `json:"album_name"` - AlbumArtist string `json:"album_artist"` - ReleaseDate string `json:"release_date"` - OutputDir string `json:"output_dir"` - FilenameFormat string `json:"filename_format"` - TrackNumber bool `json:"track_number"` - Position int `json:"position"` - DiscNumber int `json:"disc_number"` -} - -type CoverDownloadResponse struct { - Success bool `json:"success"` - Message string `json:"message"` - File string `json:"file,omitempty"` - Error string `json:"error,omitempty"` - AlreadyExists bool `json:"already_exists,omitempty"` -} - -type HeaderDownloadRequest struct { - HeaderURL string `json:"header_url"` - ArtistName string `json:"artist_name"` - OutputDir string `json:"output_dir"` -} - -type HeaderDownloadResponse struct { - Success bool `json:"success"` - Message string `json:"message"` - File string `json:"file,omitempty"` - Error string `json:"error,omitempty"` - AlreadyExists bool `json:"already_exists,omitempty"` -} - -type CoverClient struct { - httpClient *http.Client -} - -func NewCoverClient() *CoverClient { - return &CoverClient{ - httpClient: &http.Client{Timeout: 30 * time.Second}, - } -} - -func buildCoverFilename(trackName, artistName, albumName, albumArtist, releaseDate, filenameFormat string, includeTrackNumber bool, position, discNumber int) string { - safeTitle := sanitizeFilename(trackName) - safeArtist := sanitizeFilename(artistName) - safeAlbum := sanitizeFilename(albumName) - safeAlbumArtist := sanitizeFilename(albumArtist) - - year := "" - if len(releaseDate) >= 4 { - year = releaseDate[:4] - } - - var filename string - - if strings.Contains(filenameFormat, "{") { - filename = filenameFormat - filename = strings.ReplaceAll(filename, "{title}", safeTitle) - filename = strings.ReplaceAll(filename, "{artist}", safeArtist) - filename = strings.ReplaceAll(filename, "{album}", safeAlbum) - filename = strings.ReplaceAll(filename, "{album_artist}", safeAlbumArtist) - filename = strings.ReplaceAll(filename, "{year}", year) - filename = strings.ReplaceAll(filename, "{date}", sanitizeFilename(releaseDate)) - - if discNumber > 0 { - filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber)) - } else { - filename = strings.ReplaceAll(filename, "{disc}", "") - } - - if position > 0 { - filename = strings.ReplaceAll(filename, "{track}", fmt.Sprintf("%02d", position)) - } else { - - filename = regexp.MustCompile(`\{track\}\.\s*`).ReplaceAllString(filename, "") - filename = regexp.MustCompile(`\{track\}\s*-\s*`).ReplaceAllString(filename, "") - filename = regexp.MustCompile(`\{track\}\s*`).ReplaceAllString(filename, "") - } - } else { - - switch filenameFormat { - case "artist-title": - filename = fmt.Sprintf("%s - %s", safeArtist, safeTitle) - case "title-artist": - filename = fmt.Sprintf("%s - %s", safeTitle, safeArtist) - case "title": - filename = safeTitle - default: - filename = fmt.Sprintf("%s - %s", safeTitle, safeArtist) - } - - if includeTrackNumber && position > 0 { - filename = fmt.Sprintf("%02d - %s", position, filename) - } - } - - return filename + ".jpg" -} - -func convertSmallToMedium(imageURL string) string { - if strings.Contains(imageURL, spotifySize300) { - return strings.Replace(imageURL, spotifySize300, spotifySize640, 1) - } - return imageURL -} - -func (c *CoverClient) getMaxResolutionURL(imageURL string) string { - - mediumURL := convertSmallToMedium(imageURL) - if strings.Contains(mediumURL, spotifySize640) { - return strings.Replace(mediumURL, spotifySize640, spotifySizeMax, 1) - } - return mediumURL -} - -func (c *CoverClient) DownloadCoverToPath(coverURL, outputPath string, embedMaxQualityCover bool) error { - if coverURL == "" { - return fmt.Errorf("cover URL is required") - } - - downloadURL := convertSmallToMedium(coverURL) - if embedMaxQualityCover { - downloadURL = c.getMaxResolutionURL(downloadURL) - } - - resp, err := c.httpClient.Get(downloadURL) - if err != nil { - return fmt.Errorf("failed to download cover: %v", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("failed to download cover: HTTP %d", resp.StatusCode) - } - - file, err := os.Create(outputPath) - if err != nil { - return fmt.Errorf("failed to create file: %v", err) - } - defer file.Close() - - _, err = io.Copy(file, resp.Body) - if err != nil { - return fmt.Errorf("failed to write cover file: %v", err) - } - - return nil -} - -func (c *CoverClient) ApplyMacOSFLACFileIcon(filePath, coverURL string, iconSize int, embedMaxQualityCover bool) error { - if filePath == "" { - return fmt.Errorf("file path is required") - } - if coverURL == "" { - return fmt.Errorf("cover URL is required") - } - - tmpFile, err := os.CreateTemp("", "spotiflac-file-icon-*.jpg") - if err != nil { - return fmt.Errorf("failed to create temporary cover file: %w", err) - } - tmpPath := tmpFile.Name() - tmpFile.Close() - defer os.Remove(tmpPath) - - if err := c.DownloadCoverToPath(coverURL, tmpPath, embedMaxQualityCover); err != nil { - return err - } - - return SetMacOSFileIconFromImage(filePath, tmpPath, iconSize) -} - -func ResizeImageForIcon(sourcePath string, iconSize int) (string, error) { - if sourcePath == "" { - return "", fmt.Errorf("source image path is required") - } - if iconSize <= 0 { - iconSize = 256 - } - - in, err := os.Open(sourcePath) - if err != nil { - return "", fmt.Errorf("failed to open source image: %w", err) - } - defer in.Close() - - srcImage, _, err := image.Decode(in) - if err != nil { - return "", fmt.Errorf("failed to decode source image: %w", err) - } - - dst := image.NewRGBA(image.Rect(0, 0, iconSize, iconSize)) - xdraw.CatmullRom.Scale(dst, dst.Bounds(), srcImage, srcImage.Bounds(), xdraw.Over, nil) - - tmpFile, err := os.CreateTemp("", "spotiflac-resized-icon-*.png") - if err != nil { - return "", fmt.Errorf("failed to create resized icon temp file: %w", err) - } - tmpPath := tmpFile.Name() - defer tmpFile.Close() - - var encoded bytes.Buffer - if err := png.Encode(&encoded, dst); err != nil { - return "", fmt.Errorf("failed to encode resized icon image: %w", err) - } - if _, err := io.Copy(tmpFile, &encoded); err != nil { - return "", fmt.Errorf("failed to write resized icon image: %w", err) - } - - return tmpPath, nil -} - -func (c *CoverClient) DownloadCover(req CoverDownloadRequest) (*CoverDownloadResponse, error) { - if req.CoverURL == "" { - return &CoverDownloadResponse{ - Success: false, - Error: "Cover URL is required", - }, fmt.Errorf("cover URL is required") - } - - outputDir := req.OutputDir - if outputDir == "" { - outputDir = GetDefaultMusicPath() - } else { - outputDir = NormalizePath(outputDir) - } - - if err := os.MkdirAll(outputDir, 0755); err != nil { - return &CoverDownloadResponse{ - Success: false, - Error: fmt.Sprintf("failed to create output directory: %v", err), - }, err - } - - filenameFormat := req.FilenameFormat - if filenameFormat == "" { - filenameFormat = "title-artist" - } - filename := buildCoverFilename(req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, filenameFormat, req.TrackNumber, req.Position, req.DiscNumber) - filePath := filepath.Join(outputDir, filename) - - if fileInfo, err := os.Stat(filePath); err == nil && fileInfo.Size() > 0 { - return &CoverDownloadResponse{ - Success: true, - Message: "Cover file already exists", - File: filePath, - AlreadyExists: true, - }, nil - } - - downloadURL := c.getMaxResolutionURL(req.CoverURL) - - resp, err := c.httpClient.Get(downloadURL) - if err != nil { - return &CoverDownloadResponse{ - Success: false, - Error: fmt.Sprintf("failed to download cover: %v", err), - }, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return &CoverDownloadResponse{ - Success: false, - Error: fmt.Sprintf("failed to download cover: HTTP %d", resp.StatusCode), - }, fmt.Errorf("HTTP %d", resp.StatusCode) - } - - file, err := os.Create(filePath) - if err != nil { - return &CoverDownloadResponse{ - Success: false, - Error: fmt.Sprintf("failed to create file: %v", err), - }, err - } - defer file.Close() - - _, err = io.Copy(file, resp.Body) - if err != nil { - return &CoverDownloadResponse{ - Success: false, - Error: fmt.Sprintf("failed to write cover file: %v", err), - }, err - } - - return &CoverDownloadResponse{ - Success: true, - Message: "Cover downloaded successfully", - File: filePath, - }, nil -} - -func (c *CoverClient) DownloadHeader(req HeaderDownloadRequest) (*HeaderDownloadResponse, error) { - if req.HeaderURL == "" { - return &HeaderDownloadResponse{ - Success: false, - Error: "Header URL is required", - }, fmt.Errorf("header URL is required") - } - - if req.ArtistName == "" { - return &HeaderDownloadResponse{ - Success: false, - Error: "Artist name is required", - }, fmt.Errorf("artist name is required") - } - - outputDir := req.OutputDir - if outputDir == "" { - outputDir = GetDefaultMusicPath() - } else { - outputDir = NormalizePath(outputDir) - } - - artistFolder := filepath.Join(outputDir, sanitizeFilename(req.ArtistName)) - if err := os.MkdirAll(artistFolder, 0755); err != nil { - return &HeaderDownloadResponse{ - Success: false, - Error: fmt.Sprintf("failed to create artist folder: %v", err), - }, err - } - - filename := sanitizeFilename(req.ArtistName) + "_Header.jpg" - filePath := filepath.Join(artistFolder, filename) - - if fileInfo, err := os.Stat(filePath); err == nil && fileInfo.Size() > 0 { - return &HeaderDownloadResponse{ - Success: true, - Message: "Header file already exists", - File: filePath, - AlreadyExists: true, - }, nil - } - - resp, err := c.httpClient.Get(req.HeaderURL) - if err != nil { - return &HeaderDownloadResponse{ - Success: false, - Error: fmt.Sprintf("failed to download header: %v", err), - }, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return &HeaderDownloadResponse{ - Success: false, - Error: fmt.Sprintf("failed to download header: HTTP %d", resp.StatusCode), - }, fmt.Errorf("HTTP %d", resp.StatusCode) - } - - file, err := os.Create(filePath) - if err != nil { - return &HeaderDownloadResponse{ - Success: false, - Error: fmt.Sprintf("failed to create file: %v", err), - }, err - } - defer file.Close() - - _, err = io.Copy(file, resp.Body) - if err != nil { - return &HeaderDownloadResponse{ - Success: false, - Error: fmt.Sprintf("failed to write header file: %v", err), - }, err - } - - return &HeaderDownloadResponse{ - Success: true, - Message: "Header downloaded successfully", - File: filePath, - }, nil -} - -type GalleryImageDownloadRequest struct { - ImageURL string `json:"image_url"` - ArtistName string `json:"artist_name"` - ImageIndex int `json:"image_index"` - OutputDir string `json:"output_dir"` -} - -type GalleryImageDownloadResponse struct { - Success bool `json:"success"` - Message string `json:"message"` - File string `json:"file,omitempty"` - Error string `json:"error,omitempty"` - AlreadyExists bool `json:"already_exists,omitempty"` -} - -func (c *CoverClient) DownloadGalleryImage(req GalleryImageDownloadRequest) (*GalleryImageDownloadResponse, error) { - if req.ImageURL == "" { - return &GalleryImageDownloadResponse{ - Success: false, - Error: "Image URL is required", - }, fmt.Errorf("image URL is required") - } - - if req.ArtistName == "" { - return &GalleryImageDownloadResponse{ - Success: false, - Error: "Artist name is required", - }, fmt.Errorf("artist name is required") - } - - outputDir := req.OutputDir - if outputDir == "" { - outputDir = GetDefaultMusicPath() - } else { - outputDir = NormalizePath(outputDir) - } - - artistFolder := filepath.Join(outputDir, sanitizeFilename(req.ArtistName)) - if err := os.MkdirAll(artistFolder, 0755); err != nil { - return &GalleryImageDownloadResponse{ - Success: false, - Error: fmt.Sprintf("failed to create artist folder: %v", err), - }, err - } - - filename := sanitizeFilename(req.ArtistName) + fmt.Sprintf("_Gallery_%d.jpg", req.ImageIndex+1) - filePath := filepath.Join(artistFolder, filename) - - if fileInfo, err := os.Stat(filePath); err == nil && fileInfo.Size() > 0 { - return &GalleryImageDownloadResponse{ - Success: true, - Message: "Gallery image file already exists", - File: filePath, - AlreadyExists: true, - }, nil - } - - resp, err := c.httpClient.Get(req.ImageURL) - if err != nil { - return &GalleryImageDownloadResponse{ - Success: false, - Error: fmt.Sprintf("failed to download gallery image: %v", err), - }, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return &GalleryImageDownloadResponse{ - Success: false, - Error: fmt.Sprintf("failed to download gallery image: HTTP %d", resp.StatusCode), - }, fmt.Errorf("HTTP %d", resp.StatusCode) - } - - file, err := os.Create(filePath) - if err != nil { - return &GalleryImageDownloadResponse{ - Success: false, - Error: fmt.Sprintf("failed to create file: %v", err), - }, err - } - defer file.Close() - - _, err = io.Copy(file, resp.Body) - if err != nil { - return &GalleryImageDownloadResponse{ - Success: false, - Error: fmt.Sprintf("failed to write gallery image file: %v", err), - }, err - } - - return &GalleryImageDownloadResponse{ - Success: true, - Message: "Gallery image downloaded successfully", - File: filePath, - }, nil -} - -type AvatarDownloadRequest struct { - AvatarURL string `json:"avatar_url"` - ArtistName string `json:"artist_name"` - OutputDir string `json:"output_dir"` -} - -type AvatarDownloadResponse struct { - Success bool `json:"success"` - Message string `json:"message"` - File string `json:"file,omitempty"` - Error string `json:"error,omitempty"` - AlreadyExists bool `json:"already_exists,omitempty"` -} - -func (c *CoverClient) DownloadAvatar(req AvatarDownloadRequest) (*AvatarDownloadResponse, error) { - if req.AvatarURL == "" { - return &AvatarDownloadResponse{ - Success: false, - Error: "Avatar URL is required", - }, fmt.Errorf("avatar URL is required") - } - - if req.ArtistName == "" { - return &AvatarDownloadResponse{ - Success: false, - Error: "Artist name is required", - }, fmt.Errorf("artist name is required") - } - - outputDir := req.OutputDir - if outputDir == "" { - outputDir = GetDefaultMusicPath() - } else { - outputDir = NormalizePath(outputDir) - } - - artistFolder := filepath.Join(outputDir, sanitizeFilename(req.ArtistName)) - if err := os.MkdirAll(artistFolder, 0755); err != nil { - return &AvatarDownloadResponse{ - Success: false, - Error: fmt.Sprintf("failed to create artist folder: %v", err), - }, err - } - - filename := sanitizeFilename(req.ArtistName) + "_Avatar.jpg" - filePath := filepath.Join(artistFolder, filename) - - if fileInfo, err := os.Stat(filePath); err == nil && fileInfo.Size() > 0 { - return &AvatarDownloadResponse{ - Success: true, - Message: "Avatar file already exists", - File: filePath, - AlreadyExists: true, - }, nil - } - - resp, err := c.httpClient.Get(req.AvatarURL) - if err != nil { - return &AvatarDownloadResponse{ - Success: false, - Error: fmt.Sprintf("failed to download avatar: %v", err), - }, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return &AvatarDownloadResponse{ - Success: false, - Error: fmt.Sprintf("failed to download avatar: HTTP %d", resp.StatusCode), - }, fmt.Errorf("HTTP %d", resp.StatusCode) - } - - file, err := os.Create(filePath) - if err != nil { - return &AvatarDownloadResponse{ - Success: false, - Error: fmt.Sprintf("failed to create file: %v", err), - }, err - } - defer file.Close() - - _, err = io.Copy(file, resp.Body) - if err != nil { - return &AvatarDownloadResponse{ - Success: false, - Error: fmt.Sprintf("failed to write avatar file: %v", err), - }, err - } - - return &AvatarDownloadResponse{ - Success: true, - Message: "Avatar downloaded successfully", - File: filePath, - }, nil -} diff --git a/backend/download_validation.go b/backend/download_validation.go deleted file mode 100644 index a465211..0000000 --- a/backend/download_validation.go +++ /dev/null @@ -1,44 +0,0 @@ -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 -} diff --git a/backend/ffmpeg.go b/backend/ffmpeg.go deleted file mode 100644 index 04516a0..0000000 --- a/backend/ffmpeg.go +++ /dev/null @@ -1,947 +0,0 @@ -package backend - -import ( - "archive/tar" - "archive/zip" - - "fmt" - "io" - "net/http" - "os" - "os/exec" - "path/filepath" - "runtime" - "strings" - "sync" - "time" - - "github.com/ulikunitz/xz" - "golang.org/x/text/unicode/norm" -) - -type executableCandidate struct { - path string - source string -} - -func ValidateExecutable(path string) error { - cleanedPath := filepath.Clean(path) - if cleanedPath == "" { - return fmt.Errorf("empty path") - } - - if !filepath.IsAbs(cleanedPath) { - return fmt.Errorf("path must be absolute: %s", path) - } - - info, err := os.Stat(cleanedPath) - if err != nil { - return fmt.Errorf("failed to stat file: %w", err) - } - - if info.IsDir() { - return fmt.Errorf("path is a directory: %s", path) - } - - if runtime.GOOS != "windows" { - if info.Mode()&0111 == 0 { - return fmt.Errorf("file is not executable: %s", path) - } - } - - base := filepath.Base(cleanedPath) - validNames := map[string]bool{ - "ffmpeg": true, - "ffmpeg.exe": true, - "ffprobe": true, - "ffprobe.exe": true, - } - if !validNames[base] { - return fmt.Errorf("invalid executable name: %s", base) - } - - return nil -} - -func GetAppDir() (string, error) { - homeDir, err := os.UserHomeDir() - if err != nil { - return "", fmt.Errorf("failed to get home directory: %w", err) - } - return filepath.Join(homeDir, ".spotiflac"), nil -} - -func EnsureAppDir() (string, error) { - appDir, err := GetAppDir() - if err != nil { - return "", err - } - - if err := os.MkdirAll(appDir, 0o755); err != nil { - return "", fmt.Errorf("failed to create app directory: %w", err) - } - - return appDir, nil -} - -func GetFFmpegDir() (string, error) { - return EnsureAppDir() -} - -func copyExecutable(src, dst string) error { - if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { - return err - } - - in, err := os.Open(src) - if err != nil { - return err - } - defer in.Close() - - out, err := os.OpenFile(dst, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o755) - if err != nil { - return err - } - defer out.Close() - - if _, err = io.Copy(out, in); err != nil { - return err - } - - if err := out.Sync(); err != nil { - return err - } - - return prepareExecutableForUse(dst) -} - -func appendExecutableCandidate(candidates []executableCandidate, seen map[string]struct{}, path, source string) []executableCandidate { - cleanedPath := filepath.Clean(strings.TrimSpace(path)) - if cleanedPath == "" { - return candidates - } - if _, exists := seen[cleanedPath]; exists { - return candidates - } - - seen[cleanedPath] = struct{}{} - return append(candidates, executableCandidate{ - path: cleanedPath, - source: source, - }) -} - -func resolveSystemExecutable(executableName string) string { - if runtime.GOOS == "darwin" { - candidates := []string{ - "/opt/homebrew/bin/" + executableName, - "/usr/local/bin/" + executableName, - } - for _, candidate := range candidates { - if _, err := os.Stat(candidate); err == nil { - return candidate - } - } - } - - if runtime.GOOS != "windows" { - path, err := exec.Command("which", executableName).Output() - if err == nil { - trimmed := strings.TrimSpace(string(path)) - if trimmed != "" { - return trimmed - } - } - } - - path, err := exec.LookPath(executableName) - if err == nil { - return path - } - - return "" -} - -func runExecutableVersionCheck(path string) error { - cmd := exec.Command(path, "-version") - setHideWindow(cmd) - return cmd.Run() -} - -func removeMacOSQuarantineAttribute(path string) error { - cmd := exec.Command("xattr", "-d", "com.apple.quarantine", path) - setHideWindow(cmd) - output, err := cmd.CombinedOutput() - if err == nil { - return nil - } - - trimmedOutput := strings.TrimSpace(string(output)) - lowerOutput := strings.ToLower(trimmedOutput) - if strings.Contains(lowerOutput, "no such xattr") || strings.Contains(lowerOutput, "attribute not found") { - return nil - } - - if trimmedOutput != "" { - return fmt.Errorf("%w: %s", err, trimmedOutput) - } - - return err -} - -func prepareExecutableForUse(path string) error { - cleanedPath := filepath.Clean(strings.TrimSpace(path)) - if cleanedPath == "" { - return fmt.Errorf("empty path") - } - - if runtime.GOOS == "windows" { - return nil - } - - if err := os.Chmod(cleanedPath, 0755); err != nil { - return fmt.Errorf("failed to mark executable: %w", err) - } - - if runtime.GOOS == "darwin" { - if err := removeMacOSQuarantineAttribute(cleanedPath); err != nil { - fmt.Printf("[FFmpeg] Warning: failed to remove macOS quarantine from %s: %v\n", cleanedPath, err) - } - } - - return nil -} - -func resolveExecutablePath(executableName string) (string, string, error) { - ffmpegDir, err := GetFFmpegDir() - if err != nil { - return "", "", err - } - - localPath := filepath.Join(ffmpegDir, executableName) - nextDir := filepath.Join(filepath.Dir(ffmpegDir), ".spotiflac-next") - nextPath := filepath.Join(nextDir, executableName) - localExists := false - candidates := make([]executableCandidate, 0, 3) - seen := make(map[string]struct{}, 3) - - if systemPath := resolveSystemExecutable(executableName); systemPath != "" { - candidates = appendExecutableCandidate(candidates, seen, systemPath, "system") - } - - if _, err := os.Stat(localPath); err == nil { - localExists = true - candidates = appendExecutableCandidate(candidates, seen, localPath, "local") - } - - if !localExists { - if _, err := os.Stat(nextPath); err == nil { - if copyErr := copyExecutable(nextPath, localPath); copyErr == nil { - fmt.Printf("[FFmpeg] Copied %s from SpotiFLAC-Next folder\n", executableName) - candidates = appendExecutableCandidate(candidates, seen, localPath, "migrated") - } - } - } - - var lastErr error - for _, candidate := range candidates { - if candidate.source != "system" { - if err := prepareExecutableForUse(candidate.path); err != nil { - lastErr = err - fmt.Printf("[FFmpeg] Skipping %s %s: %v\n", candidate.source, candidate.path, err) - continue - } - } - - if err := ValidateExecutable(candidate.path); err != nil { - lastErr = err - fmt.Printf("[FFmpeg] Skipping %s %s: %v\n", candidate.source, candidate.path, err) - continue - } - - if err := runExecutableVersionCheck(candidate.path); err != nil { - lastErr = err - fmt.Printf("[FFmpeg] Skipping %s %s: %v\n", candidate.source, candidate.path, err) - continue - } - - return candidate.path, localPath, nil - } - - if len(candidates) > 0 { - if lastErr != nil { - return "", localPath, fmt.Errorf("no working %s executable found: %w", executableName, lastErr) - } - return "", localPath, fmt.Errorf("no working %s executable found", executableName) - } - - return "", localPath, fmt.Errorf("%s not found in app directory or system path", executableName) -} - -func GetFFmpegPath() (string, error) { - ffmpegName := "ffmpeg" - if runtime.GOOS == "windows" { - ffmpegName = "ffmpeg.exe" - } - - path, localPath, err := resolveExecutablePath(ffmpegName) - if err != nil { - if localPath != "" { - return localPath, err - } - return "", err - } - - return path, nil -} - -func GetFFprobePath() (string, error) { - ffprobeName := "ffprobe" - if runtime.GOOS == "windows" { - ffprobeName = "ffprobe.exe" - } - - path, localPath, err := resolveExecutablePath(ffprobeName) - if err != nil { - if localPath != "" { - return localPath, err - } - return "", err - } - - return path, nil -} - -func IsFFprobeInstalled() (bool, error) { - _, err := GetFFprobePath() - return err == nil, nil -} - -func IsFFmpegInstalled() (bool, error) { - if _, err := GetFFmpegPath(); err != nil { - return false, nil - } - - return IsFFprobeInstalled() -} - -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 ffmpegReleaseBaseURL = "https://github.com/afkarxyz/ffmpeg-binaries/releases/download/v8.1" - -func buildFFmpegReleaseURL(assetName string) string { - return ffmpegReleaseBaseURL + "/" + assetName -} - -func getFFmpegDownloadURLs() ([]string, []string, error) { - switch runtime.GOOS { - case "windows": - return []string{buildFFmpegReleaseURL("ffmpeg-windows.zip")}, []string{buildFFmpegReleaseURL("ffprobe-windows.zip")}, nil - case "linux": - switch runtime.GOARCH { - case "amd64": - return []string{buildFFmpegReleaseURL("ffmpeg-linux-amd64.zip")}, []string{buildFFmpegReleaseURL("ffprobe-linux-amd64.zip")}, nil - case "arm64": - return []string{buildFFmpegReleaseURL("ffmpeg-linux-arm64v8.zip")}, []string{buildFFmpegReleaseURL("ffprobe-linux-arm64v8.zip")}, nil - default: - return nil, nil, fmt.Errorf("unsupported Linux architecture: %s", runtime.GOARCH) - } - case "darwin": - switch runtime.GOARCH { - case "amd64": - return []string{buildFFmpegReleaseURL("ffmpeg-macos-amd64.zip")}, []string{buildFFmpegReleaseURL("ffprobe-macos-amd64.zip")}, nil - case "arm64": - return []string{buildFFmpegReleaseURL("ffmpeg-macos-arm64.zip")}, []string{buildFFmpegReleaseURL("ffprobe-macos-arm64.zip")}, nil - default: - return nil, nil, fmt.Errorf("unsupported macOS architecture: %s", runtime.GOARCH) - } - default: - return nil, nil, fmt.Errorf("unsupported operating system: %s", runtime.GOOS) - } -} - -func DownloadFFmpeg(progressCallback func(int)) error { - - SetDownloadProgress(0) - SetDownloadSpeed(0) - SetDownloading(true) - defer SetDownloading(false) - - ffmpegDir, err := GetFFmpegDir() - if err != nil { - return err - } - - if err := os.MkdirAll(ffmpegDir, 0755); err != nil { - return fmt.Errorf("failed to create ffmpeg directory: %w", err) - } - - ffmpegInstalled, _ := IsFFmpegInstalled() - ffprobeInstalled, _ := IsFFprobeInstalled() - - ffmpegURLs, ffprobeURLs, err := getFFmpegDownloadURLs() - if err != nil { - return err - } - - if !ffmpegInstalled && !ffprobeInstalled { - if err := downloadWithFallback(ffmpegURLs, ffmpegDir, progressCallback, 0, 50); err != nil { - return err - } - if err := downloadWithFallback(ffprobeURLs, ffmpegDir, progressCallback, 50, 100); err != nil { - return err - } - return nil - } - - if !ffmpegInstalled { - return downloadWithFallback(ffmpegURLs, ffmpegDir, progressCallback, 0, 100) - } - - if !ffprobeInstalled { - return downloadWithFallback(ffprobeURLs, ffmpegDir, progressCallback, 0, 100) - } - - 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 { - - tmpFile, err := os.CreateTemp("", "ffmpeg-*") - if err != nil { - return fmt.Errorf("failed to create temp file: %w", err) - } - defer os.Remove(tmpFile.Name()) - defer tmpFile.Close() - - 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 { - return fmt.Errorf("failed to download: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("failed to download: HTTP %d", resp.StatusCode) - } - - totalSize := resp.ContentLength - var downloaded int64 - lastTime := time.Now() - var lastBytes int64 - - if totalSize > 0 { - totalSizeMB := float64(totalSize) / (1024 * 1024) - fmt.Printf("[FFmpeg] Total size: %.2f MB\n", totalSizeMB) - } else { - fmt.Printf("[FFmpeg] Downloading... (size unknown)\n") - } - - buf := make([]byte, 32*1024) - for { - n, err := resp.Body.Read(buf) - if n > 0 { - _, writeErr := tmpFile.Write(buf[:n]) - if writeErr != nil { - return fmt.Errorf("failed to write to temp file: %w", writeErr) - } - downloaded += int64(n) - - mbDownloaded := float64(downloaded) / (1024 * 1024) - now := time.Now() - timeDiff := now.Sub(lastTime).Seconds() - var speedMBps float64 - - if timeDiff > 0.1 { - bytesDiff := float64(downloaded - lastBytes) - speedMBps = (bytesDiff / (1024 * 1024)) / timeDiff - lastTime = now - lastBytes = downloaded - } - - SetDownloadProgress(mbDownloaded) - if speedMBps > 0 { - SetDownloadSpeed(speedMBps) - } - - if totalSize > 0 && progressCallback != nil { - rawProgress := float64(downloaded) / float64(totalSize) - scaledProgress := progressStart + int(rawProgress*float64(progressEnd-progressStart)) - progressCallback(scaledProgress) - } - - if totalSize > 0 { - percent := float64(downloaded) * 100 / float64(totalSize) - if speedMBps > 0 { - fmt.Printf("\r[FFmpeg] Downloading: %.2f MB / %.2f MB (%.1f%%) - %.2f MB/s", - mbDownloaded, float64(totalSize)/(1024*1024), percent, speedMBps) - } else { - fmt.Printf("\r[FFmpeg] Downloading: %.2f MB / %.2f MB (%.1f%%)", - mbDownloaded, float64(totalSize)/(1024*1024), percent) - } - } else { - if speedMBps > 0 { - fmt.Printf("\r[FFmpeg] Downloading: %.2f MB - %.2f MB/s", mbDownloaded, speedMBps) - } else { - fmt.Printf("\r[FFmpeg] Downloading: %.2f MB", mbDownloaded) - } - } - } - if err == io.EOF { - break - } - if err != nil { - return fmt.Errorf("failed to read response: %w", err) - } - } - - tmpFile.Close() - - if totalSize > 0 { - fmt.Printf("\r[FFmpeg] Download complete: %.2f MB / %.2f MB (100%%) \n", - float64(downloaded)/(1024*1024), float64(totalSize)/(1024*1024)) - } else { - fmt.Printf("\r[FFmpeg] Download complete: %.2f MB \n", float64(downloaded)/(1024*1024)) - } - fmt.Printf("[FFmpeg] Extracting...\n") - - if strings.HasSuffix(url, ".tar.xz") { - return extractTarXz(tmpFile.Name(), destDir) - } - if strings.HasSuffix(url, ".zip") { - return extractZip(tmpFile.Name(), destDir) - } - return fmt.Errorf("unsupported archive format for %s", url) -} - -func extractZip(zipPath, destDir string) error { - r, err := zip.OpenReader(zipPath) - if err != nil { - return fmt.Errorf("failed to open zip: %w", err) - } - defer r.Close() - - ffmpegName := "ffmpeg" - ffprobeName := "ffprobe" - if runtime.GOOS == "windows" { - ffmpegName = "ffmpeg.exe" - ffprobeName = "ffprobe.exe" - } - - foundFFmpeg := false - foundFFprobe := false - - for _, f := range r.File { - baseName := filepath.Base(f.Name) - if f.FileInfo().IsDir() { - continue - } - - var destPath string - if baseName == ffmpegName { - destPath = filepath.Join(destDir, ffmpegName) - foundFFmpeg = true - } else if baseName == ffprobeName { - destPath = filepath.Join(destDir, ffprobeName) - foundFFprobe = true - } else { - - continue - } - - fmt.Printf("[FFmpeg] Found: %s\n", f.Name) - - rc, err := f.Open() - if err != nil { - return fmt.Errorf("failed to open file in zip: %w", err) - } - - outFile, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0755) - if err != nil { - rc.Close() - return fmt.Errorf("failed to create output file: %w", err) - } - - _, err = io.Copy(outFile, rc) - rc.Close() - outFile.Close() - - if err != nil { - return fmt.Errorf("failed to extract file: %w", err) - } - - if err := prepareExecutableForUse(destPath); err != nil { - return fmt.Errorf("failed to prepare extracted executable: %w", err) - } - - fmt.Printf("[FFmpeg] Extracted to: %s\n", destPath) - } - - if !foundFFmpeg && !foundFFprobe { - return fmt.Errorf("neither ffmpeg nor ffprobe found in archive") - } - - if foundFFmpeg { - fmt.Printf("[FFmpeg] ffmpeg extracted successfully\n") - } - if foundFFprobe { - fmt.Printf("[FFmpeg] ffprobe extracted successfully\n") - } - - return nil -} - -func extractTarXz(tarXzPath, destDir string) error { - file, err := os.Open(tarXzPath) - if err != nil { - return fmt.Errorf("failed to open tar.xz: %w", err) - } - defer file.Close() - - xzReader, err := xz.NewReader(file) - if err != nil { - return fmt.Errorf("failed to create xz reader: %w", err) - } - - tarReader := tar.NewReader(xzReader) - - ffmpegName := "ffmpeg" - ffprobeName := "ffprobe" - foundFFmpeg := false - foundFFprobe := false - - for { - header, err := tarReader.Next() - if err == io.EOF { - break - } - if err != nil { - return fmt.Errorf("failed to read tar: %w", err) - } - - if header.Typeflag != tar.TypeReg { - continue - } - - baseName := filepath.Base(header.Name) - var destPath string - - if baseName == ffmpegName { - destPath = filepath.Join(destDir, ffmpegName) - foundFFmpeg = true - } else if baseName == ffprobeName { - destPath = filepath.Join(destDir, ffprobeName) - foundFFprobe = true - } else { - - continue - } - - fmt.Printf("[FFmpeg] Found: %s\n", header.Name) - - outFile, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0755) - if err != nil { - return fmt.Errorf("failed to create output file: %w", err) - } - - _, err = io.Copy(outFile, tarReader) - outFile.Close() - - if err != nil { - return fmt.Errorf("failed to extract file: %w", err) - } - - if err := prepareExecutableForUse(destPath); err != nil { - return fmt.Errorf("failed to prepare extracted executable: %w", err) - } - - fmt.Printf("[FFmpeg] Extracted to: %s\n", destPath) - } - - if !foundFFmpeg && !foundFFprobe { - return fmt.Errorf("neither ffmpeg nor ffprobe found in archive") - } - - if foundFFmpeg { - fmt.Printf("[FFmpeg] ffmpeg extracted successfully\n") - } - if foundFFprobe { - fmt.Printf("[FFmpeg] ffprobe extracted successfully\n") - } - - return nil -} - -type ConvertAudioRequest struct { - InputFiles []string `json:"input_files"` - OutputFormat string `json:"output_format"` - Bitrate string `json:"bitrate"` - Codec string `json:"codec"` -} - -type ConvertAudioResult struct { - InputFile string `json:"input_file"` - OutputFile string `json:"output_file"` - Success bool `json:"success"` - Error string `json:"error,omitempty"` -} - -func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, 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") - } - - results := make([]ConvertAudioResult, len(req.InputFiles)) - var wg sync.WaitGroup - var mu sync.Mutex - - for i, inputFile := range req.InputFiles { - wg.Add(1) - go func(idx int, inputFile string) { - defer wg.Done() - - result := ConvertAudioResult{ - InputFile: inputFile, - } - - inputExt := strings.ToLower(filepath.Ext(inputFile)) - baseName := strings.TrimSuffix(filepath.Base(inputFile), inputExt) - inputDir := filepath.Dir(inputFile) - - outputFormatUpper := strings.ToUpper(req.OutputFormat) - outputDir := filepath.Join(inputDir, outputFormatUpper) - - 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 - } - - outputExt := "." + strings.ToLower(req.OutputFormat) - outputFile := filepath.Join(outputDir, baseName+outputExt) - outputFile = norm.NFC.String(outputFile) - - if inputExt == outputExt { - result.Error = "Input and output formats are the same" - result.Success = false - mu.Lock() - results[idx] = result - mu.Unlock() - return - } - - result.OutputFile = outputFile - - var coverArtPath string - var lyrics string - var inputMetadata Metadata - - inputMetadata, err = ExtractFullMetadataFromFile(inputFile) - if err != nil { - fmt.Printf("[FFmpeg] Warning: Failed to extract metadata from %s: %v\n", inputFile, err) - } - - 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) - if err != nil { - fmt.Printf("[FFmpeg] Warning: Failed to extract lyrics from %s: %v\n", inputFile, err) - } else if lyrics != "" { - fmt.Printf("[FFmpeg] Lyrics extracted from %s: %d characters\n", inputFile, len(lyrics)) - } else { - fmt.Printf("[FFmpeg] No lyrics found in %s\n", inputFile) - } - - inputMetadata.Lyrics = lyrics - - args := []string{ - "-i", inputFile, - "-y", - } - - switch req.OutputFormat { - case "mp3": - args = append(args, - "-codec:a", "libmp3lame", - "-b:a", req.Bitrate, - "-map", "0:a", - "-id3v2_version", "3", - ) - case "m4a": - - codec := req.Codec - if codec == "" { - codec = "aac" - } - - if codec == "alac" { - - args = append(args, - "-codec:a", "alac", - "-map", "0:a", - ) - } else { - - args = append(args, - "-codec:a", "aac", - "-b:a", req.Bitrate, - "-map", "0:a", - ) - } - } - - args = append(args, outputFile) - - fmt.Printf("[FFmpeg] Converting: %s -> %s\n", inputFile, outputFile) - - cmd := exec.Command(ffmpegPath, args...) - - setHideWindow(cmd) - output, err := cmd.CombinedOutput() - if err != nil { - result.Error = fmt.Sprintf("conversion failed: %s - %s", err.Error(), string(output)) - result.Success = false - mu.Lock() - results[idx] = result - mu.Unlock() - - if coverArtPath != "" { - os.Remove(coverArtPath) - } - return - } - - if err := EmbedMetadataToConvertedFile(outputFile, inputMetadata, coverArtPath); err != nil { - fmt.Printf("[FFmpeg] Warning: Failed to embed metadata: %v\n", err) - } else { - fmt.Printf("[FFmpeg] Metadata embedded successfully\n") - } - - if lyrics != "" { - if err := EmbedLyricsOnlyUniversal(outputFile, lyrics); err != nil { - fmt.Printf("[FFmpeg] Warning: Failed to embed lyrics: %v\n", err) - } else { - fmt.Printf("[FFmpeg] Lyrics embedded successfully\n") - } - } - - if coverArtPath != "" { - os.Remove(coverArtPath) - } - - result.Success = true - fmt.Printf("[FFmpeg] Successfully converted: %s\n", outputFile) - - mu.Lock() - results[idx] = result - mu.Unlock() - }(i, inputFile) - } - - wg.Wait() - return results, nil -} - -type AudioFileInfo struct { - Path string `json:"path"` - Filename string `json:"filename"` - Format string `json:"format"` - Size int64 `json:"size"` -} - -func GetAudioFileInfo(filePath string) (*AudioFileInfo, error) { - info, err := os.Stat(filePath) - if err != nil { - return nil, err - } - - ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(filePath), ".")) - return &AudioFileInfo{ - Path: filePath, - Filename: filepath.Base(filePath), - Format: ext, - Size: info.Size(), - }, nil -} diff --git a/backend/ffmpeg_unix.go b/backend/ffmpeg_unix.go deleted file mode 100644 index 73b262f..0000000 --- a/backend/ffmpeg_unix.go +++ /dev/null @@ -1,12 +0,0 @@ -//go:build !windows -// +build !windows - -package backend - -import ( - "os/exec" -) - -func setHideWindow(cmd *exec.Cmd) { - -} diff --git a/backend/ffmpeg_windows.go b/backend/ffmpeg_windows.go deleted file mode 100644 index c08640e..0000000 --- a/backend/ffmpeg_windows.go +++ /dev/null @@ -1,15 +0,0 @@ -//go:build windows -// +build windows - -package backend - -import ( - "os/exec" - "syscall" -) - -func setHideWindow(cmd *exec.Cmd) { - cmd.SysProcAttr = &syscall.SysProcAttr{ - HideWindow: true, - } -} diff --git a/backend/file_dialog.go b/backend/file_dialog.go deleted file mode 100644 index a2f740f..0000000 --- a/backend/file_dialog.go +++ /dev/null @@ -1,53 +0,0 @@ -package backend - -import ( - "context" - - "github.com/wailsapp/wails/v2/pkg/runtime" -) - -func SelectMultipleFiles(ctx context.Context) ([]string, error) { - files, err := runtime.OpenMultipleFilesDialog(ctx, runtime.OpenDialogOptions{ - Title: "Select Audio Files", - Filters: []runtime.FileFilter{ - { - DisplayName: "Audio Files (*.mp3, *.m4a, *.flac, *.aac)", - Pattern: "*.mp3;*.m4a;*.flac;*.aac", - }, - { - DisplayName: "MP3 Files (*.mp3)", - Pattern: "*.mp3", - }, - { - DisplayName: "M4A Files (*.m4a)", - Pattern: "*.m4a", - }, - { - DisplayName: "FLAC Files (*.flac)", - Pattern: "*.flac", - }, - { - DisplayName: "AAC Files (*.aac)", - Pattern: "*.aac", - }, - { - DisplayName: "All Files (*.*)", - Pattern: "*.*", - }, - }, - }) - if err != nil { - return nil, err - } - return files, nil -} - -func SelectOutputDirectory(ctx context.Context) (string, error) { - dir, err := runtime.OpenDirectoryDialog(ctx, runtime.OpenDialogOptions{ - Title: "Select Output Directory", - }) - if err != nil { - return "", err - } - return dir, nil -} diff --git a/backend/fileicon_darwin.go b/backend/fileicon_darwin.go deleted file mode 100644 index dc62cbf..0000000 --- a/backend/fileicon_darwin.go +++ /dev/null @@ -1,45 +0,0 @@ -//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 -} diff --git a/backend/fileicon_stub.go b/backend/fileicon_stub.go deleted file mode 100644 index 31be330..0000000 --- a/backend/fileicon_stub.go +++ /dev/null @@ -1,7 +0,0 @@ -//go:build !darwin - -package backend - -func SetMacOSFileIconFromImage(filePath, imagePath string, iconSize int) error { - return nil -} diff --git a/backend/filemanager.go b/backend/filemanager.go deleted file mode 100644 index 12f3b33..0000000 --- a/backend/filemanager.go +++ /dev/null @@ -1,503 +0,0 @@ -package backend - -import ( - "encoding/json" - "fmt" - "os" - "os/exec" - "path/filepath" - "strconv" - "strings" - - id3v2 "github.com/bogem/id3v2/v2" - "github.com/go-flac/flacvorbis" - "github.com/go-flac/go-flac" -) - -type FileInfo struct { - Name string `json:"name"` - Path string `json:"path"` - IsDir bool `json:"is_dir"` - Size int64 `json:"size"` - Children []FileInfo `json:"children,omitempty"` -} - -type AudioMetadata struct { - Title string `json:"title"` - Artist string `json:"artist"` - Album string `json:"album"` - AlbumArtist string `json:"album_artist"` - TrackNumber int `json:"track_number"` - DiscNumber int `json:"disc_number"` - Year string `json:"year"` - ISRC string `json:"isrc"` - UPC string `json:"upc"` -} - -type RenamePreview struct { - OldPath string `json:"old_path"` - OldName string `json:"old_name"` - NewName string `json:"new_name"` - NewPath string `json:"new_path"` - Error string `json:"error,omitempty"` - Metadata AudioMetadata `json:"metadata"` -} - -type RenameResult struct { - OldPath string `json:"old_path"` - NewPath string `json:"new_path"` - Success bool `json:"success"` - Error string `json:"error,omitempty"` -} - -func ListDirectory(dirPath string) ([]FileInfo, error) { - entries, err := os.ReadDir(dirPath) - if err != nil { - return nil, fmt.Errorf("failed to read directory: %w", err) - } - - var result []FileInfo - for _, entry := range entries { - info, err := entry.Info() - if err != nil { - continue - } - - fileInfo := FileInfo{ - Name: entry.Name(), - Path: filepath.Join(dirPath, entry.Name()), - IsDir: entry.IsDir(), - Size: info.Size(), - } - - if entry.IsDir() { - children, err := ListDirectory(fileInfo.Path) - if err == nil { - fileInfo.Children = children - } - } - - result = append(result, fileInfo) - } - - return result, nil -} - -func ListAudioFiles(dirPath string) ([]FileInfo, error) { - var result []FileInfo - - err := filepath.Walk(dirPath, func(path string, info os.FileInfo, err error) error { - if err != nil { - return nil - } - - if info.IsDir() { - return nil - } - - ext := strings.ToLower(filepath.Ext(path)) - if ext == ".flac" || ext == ".mp3" || ext == ".m4a" || ext == ".aac" { - result = append(result, FileInfo{ - Name: info.Name(), - Path: path, - IsDir: false, - Size: info.Size(), - }) - } - - return nil - }) - - if err != nil { - return nil, fmt.Errorf("failed to walk directory: %w", err) - } - - return result, nil -} - -func ReadAudioMetadata(filePath string) (*AudioMetadata, error) { - if !fileExists(filePath) { - return nil, fmt.Errorf("file does not exist") - } - - ext := strings.ToLower(filepath.Ext(filePath)) - - switch ext { - case ".flac": - return readFlacMetadata(filePath) - case ".mp3": - return readMp3Metadata(filePath) - case ".m4a": - return readM4aMetadata(filePath) - default: - return nil, fmt.Errorf("unsupported file format: %s", ext) - } -} - -func readFlacMetadata(filePath string) (*AudioMetadata, error) { - f, err := flac.ParseFile(filePath) - if err != nil { - return nil, fmt.Errorf("failed to parse FLAC file: %w", err) - } - - metadata := &AudioMetadata{} - - for _, block := range f.Meta { - if block.Type == flac.VorbisComment { - cmt, err := flacvorbis.ParseFromMetaDataBlock(*block) - if err != nil { - continue - } - - for _, comment := range cmt.Comments { - parts := strings.SplitN(comment, "=", 2) - if len(parts) != 2 { - continue - } - - fieldName := strings.ToUpper(parts[0]) - value := parts[1] - - switch fieldName { - case "TITLE": - metadata.Title = value - case "ARTIST": - metadata.Artist = value - case "ALBUM": - metadata.Album = value - case "ALBUMARTIST": - metadata.AlbumArtist = value - case "TRACKNUMBER": - if num, err := strconv.Atoi(value); err == nil { - metadata.TrackNumber = num - } - case "DISCNUMBER": - if num, err := strconv.Atoi(value); err == nil { - metadata.DiscNumber = num - } - case "DATE", "YEAR": - metadata.Year = value - case "ISRC", "TSRC": - metadata.ISRC = value - case "UPC": - assignPreferredUPC(&metadata.UPC, value, true) - case "BARCODE": - assignPreferredUPC(&metadata.UPC, value, false) - } - } - } - } - - return metadata, nil -} - -func readMp3Metadata(filePath string) (*AudioMetadata, error) { - tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true}) - if err != nil { - return nil, fmt.Errorf("failed to open MP3 file: %w", err) - } - defer tag.Close() - - metadata := &AudioMetadata{ - Title: tag.Title(), - Artist: tag.Artist(), - Album: tag.Album(), - Year: tag.Year(), - } - - if frames := tag.GetFrames("TPE2"); len(frames) > 0 { - if textFrame, ok := frames[0].(id3v2.TextFrame); ok { - metadata.AlbumArtist = textFrame.Text - } - } - - if frames := tag.GetFrames(tag.CommonID("Track number/Position in set")); len(frames) > 0 { - if textFrame, ok := frames[0].(id3v2.TextFrame); ok { - trackStr := strings.Split(textFrame.Text, "/")[0] - if num, err := strconv.Atoi(trackStr); err == nil { - metadata.TrackNumber = num - } - } - } - - if frames := tag.GetFrames(tag.CommonID("Part of a set")); len(frames) > 0 { - if textFrame, ok := frames[0].(id3v2.TextFrame); ok { - discStr := strings.Split(textFrame.Text, "/")[0] - if num, err := strconv.Atoi(discStr); err == nil { - metadata.DiscNumber = num - } - } - } - - if frames := tag.GetFrames("TSRC"); len(frames) > 0 { - if textFrame, ok := frames[0].(id3v2.TextFrame); ok { - metadata.ISRC = textFrame.Text - } - } - if frames := tag.GetFrames("TXXX"); len(frames) > 0 { - for _, frame := range frames { - userTextFrame, ok := frame.(id3v2.UserDefinedTextFrame) - if !ok { - continue - } - matched, preferred := classifyUPCDescription(userTextFrame.Description) - if !matched { - continue - } - assignPreferredUPC(&metadata.UPC, userTextFrame.Value, preferred) - if preferred && strings.TrimSpace(metadata.UPC) != "" { - break - } - } - } - - return metadata, nil -} - -func readMetadataWithFFprobe(filePath string) (*AudioMetadata, error) { - ffprobePath, err := GetFFprobePath() - if err != nil { - return nil, err - } - - if err := ValidateExecutable(ffprobePath); err != nil { - return nil, fmt.Errorf("invalid ffprobe executable: %w", err) - } - - cmd := exec.Command(ffprobePath, - "-v", "quiet", - "-print_format", "json", - "-show_format", - "-show_streams", - filePath, - ) - - setHideWindow(cmd) - - output, err := cmd.Output() - if err != nil { - return nil, err - } - - var result struct { - Format struct { - Tags map[string]string `json:"tags"` - } `json:"format"` - Streams []struct { - Tags map[string]string `json:"tags"` - } `json:"streams"` - } - - if err := json.Unmarshal(output, &result); err != nil { - return nil, err - } - - metadata := &AudioMetadata{} - - allTags := make(map[string]string) - - for _, stream := range result.Streams { - for key, value := range stream.Tags { - allTags[strings.ToLower(key)] = value - } - } - - for key, value := range result.Format.Tags { - allTags[strings.ToLower(key)] = value - } - - for key, value := range allTags { - switch key { - case "title": - metadata.Title = value - case "artist": - metadata.Artist = value - case "album": - metadata.Album = value - case "album_artist", "albumartist": - metadata.AlbumArtist = value - case "track": - - trackStr := strings.Split(value, "/")[0] - if num, err := strconv.Atoi(trackStr); err == nil { - metadata.TrackNumber = num - } - case "disc": - discStr := strings.Split(value, "/")[0] - if num, err := strconv.Atoi(discStr); err == nil { - metadata.DiscNumber = num - } - case "date", "year": - if metadata.Year == "" || len(value) > len(metadata.Year) { - metadata.Year = value - } - case "isrc", "tsrc": - metadata.ISRC = value - } - } - - metadata.UPC = firstPreferredFFprobeUPCValue(allTags) - - return metadata, nil -} - -func readM4aMetadata(filePath string) (*AudioMetadata, error) { - metadata, err := readMetadataWithFFprobe(filePath) - if err != nil { - return &AudioMetadata{}, nil - } - return metadata, nil -} - -func GenerateFilename(metadata *AudioMetadata, format string, ext string) string { - if metadata == nil { - return "" - } - - result := format - - year := metadata.Year - if len(year) >= 4 { - year = year[:4] - } - - result = strings.ReplaceAll(result, "{title}", sanitizeFilenameForRename(metadata.Title)) - result = strings.ReplaceAll(result, "{artist}", sanitizeFilenameForRename(metadata.Artist)) - result = strings.ReplaceAll(result, "{album}", sanitizeFilenameForRename(metadata.Album)) - result = strings.ReplaceAll(result, "{album_artist}", sanitizeFilenameForRename(metadata.AlbumArtist)) - result = strings.ReplaceAll(result, "{year}", sanitizeFilenameForRename(year)) - result = strings.ReplaceAll(result, "{date}", sanitizeFilenameForRename(metadata.Year)) - result = strings.ReplaceAll(result, "{isrc}", sanitizeFilenameForRename(metadata.ISRC)) - - if metadata.TrackNumber > 0 { - result = strings.ReplaceAll(result, "{track}", fmt.Sprintf("%02d", metadata.TrackNumber)) - } else { - result = strings.ReplaceAll(result, "{track}", "") - } - - if metadata.DiscNumber > 0 { - result = strings.ReplaceAll(result, "{disc}", fmt.Sprintf("%d", metadata.DiscNumber)) - } else { - result = strings.ReplaceAll(result, "{disc}", "") - } - - result = strings.TrimSpace(result) - result = strings.Join(strings.Fields(result), " ") - - result = strings.Trim(result, " -._") - - if result == "" { - return "" - } - - return result + ext -} - -func sanitizeFilenameForRename(name string) string { - - invalid := []string{"<", ">", ":", "\"", "/", "\\", "|", "?", "*"} - result := name - for _, char := range invalid { - result = strings.ReplaceAll(result, char, "") - } - return strings.TrimSpace(result) -} - -func PreviewRename(files []string, format string) []RenamePreview { - var previews []RenamePreview - - for _, filePath := range files { - preview := RenamePreview{ - OldPath: filePath, - OldName: filepath.Base(filePath), - } - - metadata, err := ReadAudioMetadata(filePath) - if err != nil { - preview.Error = err.Error() - previews = append(previews, preview) - continue - } - - preview.Metadata = *metadata - - ext := filepath.Ext(filePath) - newName := GenerateFilename(metadata, format, ext) - - if newName == "" { - preview.Error = "Could not generate filename (missing metadata)" - previews = append(previews, preview) - continue - } - - preview.NewName = newName - preview.NewPath = filepath.Join(filepath.Dir(filePath), newName) - - previews = append(previews, preview) - } - - return previews -} - -func GetFileSizes(files []string) map[string]int64 { - result := make(map[string]int64) - for _, filePath := range files { - info, err := os.Stat(filePath) - if err == nil { - result[filePath] = info.Size() - } - } - return result -} - -func RenameFiles(files []string, format string) []RenameResult { - var results []RenameResult - - for _, filePath := range files { - result := RenameResult{ - OldPath: filePath, - } - - metadata, err := ReadAudioMetadata(filePath) - if err != nil { - result.Error = err.Error() - result.Success = false - results = append(results, result) - continue - } - - ext := filepath.Ext(filePath) - newName := GenerateFilename(metadata, format, ext) - - if newName == "" { - result.Error = "Could not generate filename (missing metadata)" - result.Success = false - results = append(results, result) - continue - } - - newPath := filepath.Join(filepath.Dir(filePath), newName) - result.NewPath = newPath - - if newPath != filePath { - if _, err := os.Stat(newPath); err == nil { - result.Error = "File already exists" - result.Success = false - results = append(results, result) - continue - } - } - - if err := os.Rename(filePath, newPath); err != nil { - result.Error = err.Error() - result.Success = false - results = append(results, result) - continue - } - - result.Success = true - results = append(results, result) - } - - return results -} diff --git a/backend/filename.go b/backend/filename.go deleted file mode 100644 index 91ae94d..0000000 --- a/backend/filename.go +++ /dev/null @@ -1,239 +0,0 @@ -package backend - -import ( - "fmt" - "os" - "path/filepath" - "regexp" - "strings" - "unicode" - "unicode/utf8" -) - -func buildFormattedFilenameBase(trackName, artistName, albumName, albumArtist, releaseDate, filenameFormat, playlistName, playlistOwner, isrc string, includeTrackNumber bool, position, discNumber int, useAlbumTrackNumber bool) string { - safeTitle := SanitizeFilename(trackName) - safeArtist := SanitizeFilename(artistName) - safeAlbum := SanitizeFilename(albumName) - safeAlbumArtist := SanitizeFilename(albumArtist) - safeISRC := SanitizeOptionalFilename(isrc) - - safePlaylist := SanitizeFilename(playlistName) - safeCreator := SanitizeFilename(playlistOwner) - - year := "" - if len(releaseDate) >= 4 { - year = releaseDate[:4] - } - - var filename string - - if strings.Contains(filenameFormat, "{") { - filename = filenameFormat - filename = strings.ReplaceAll(filename, "{title}", safeTitle) - filename = strings.ReplaceAll(filename, "{artist}", safeArtist) - filename = strings.ReplaceAll(filename, "{album}", safeAlbum) - filename = strings.ReplaceAll(filename, "{album_artist}", safeAlbumArtist) - filename = strings.ReplaceAll(filename, "{year}", year) - filename = strings.ReplaceAll(filename, "{date}", SanitizeFilename(releaseDate)) - filename = strings.ReplaceAll(filename, "{playlist}", safePlaylist) - filename = strings.ReplaceAll(filename, "{creator}", safeCreator) - filename = strings.ReplaceAll(filename, "{isrc}", safeISRC) - - if discNumber > 0 { - filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber)) - } else { - filename = strings.ReplaceAll(filename, "{disc}", "") - } - - if position > 0 { - filename = strings.ReplaceAll(filename, "{track}", fmt.Sprintf("%02d", position)) - } else { - - filename = regexp.MustCompile(`\{track\}\.\s*`).ReplaceAllString(filename, "") - filename = regexp.MustCompile(`\{track\}\s*-\s*`).ReplaceAllString(filename, "") - filename = regexp.MustCompile(`\{track\}\s*`).ReplaceAllString(filename, "") - } - } else { - - switch filenameFormat { - case "artist-title": - filename = fmt.Sprintf("%s - %s", safeArtist, safeTitle) - case "title": - filename = safeTitle - default: - filename = fmt.Sprintf("%s - %s", safeTitle, safeArtist) - } - - if includeTrackNumber && position > 0 { - filename = fmt.Sprintf("%02d. %s", position, filename) - } - } - - return filename -} - -func BuildExpectedFilename(trackName, artistName, albumName, albumArtist, releaseDate, filenameFormat, playlistName, playlistOwner string, includeTrackNumber bool, position, discNumber int, useAlbumTrackNumber bool, extra ...string) string { - isrc := "" - if len(extra) > 0 { - isrc = extra[0] - } - - return buildFormattedFilenameBase(trackName, artistName, albumName, albumArtist, releaseDate, filenameFormat, playlistName, playlistOwner, isrc, includeTrackNumber, position, discNumber, useAlbumTrackNumber) + ".flac" -} - -func ResolveOutputPathForDownload(path string, redownloadWithSuffix bool) (string, bool) { - if !redownloadWithSuffix { - if info, err := os.Stat(path); err == nil && info.Size() > 0 { - return path, true - } - return path, false - } - - if info, err := os.Stat(path); err != nil || info.Size() == 0 { - return path, false - } - - ext := filepath.Ext(path) - base := strings.TrimSuffix(path, ext) - - for i := 1; ; i++ { - candidate := fmt.Sprintf("%s_%02d%s", base, i, ext) - if info, err := os.Stat(candidate); err != nil || info.Size() == 0 { - return candidate, false - } - } -} - -func mustFileSize(path string) int64 { - info, err := os.Stat(path) - if err != nil { - return 0 - } - return info.Size() -} - -func SanitizeFilename(name string) string { - - sanitized := strings.ReplaceAll(name, "/", " ") - - re := regexp.MustCompile(`[<>:"\\|?*]`) - sanitized = re.ReplaceAllString(sanitized, " ") - - var result strings.Builder - for _, r := range sanitized { - - if r < 0x20 && r != 0x09 && r != 0x0A && r != 0x0D { - continue - } - if r == 0x7F { - continue - } - - if unicode.IsControl(r) && r != 0x09 && r != 0x0A && r != 0x0D { - continue - } - - result.WriteRune(r) - } - - sanitized = result.String() - sanitized = strings.TrimSpace(sanitized) - - sanitized = strings.Trim(sanitized, ". ") - - re = regexp.MustCompile(`\s+`) - sanitized = re.ReplaceAllString(sanitized, " ") - - re = regexp.MustCompile(`_+`) - sanitized = re.ReplaceAllString(sanitized, "_") - - sanitized = strings.Trim(sanitized, "_ ") - - if sanitized == "" { - return "Unknown" - } - - if !utf8.ValidString(sanitized) { - - sanitized = strings.ToValidUTF8(sanitized, "_") - } - - return sanitized -} - -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)) -} - -func GetSeparator() string { - settings, err := LoadConfigSettings() - if err != nil || settings == nil { - return "; " - } - - if sep, ok := settings["separator"].(string); ok { - if sep == "comma" { - return ", " - } - if sep == "semicolon" { - return "; " - } - } - return "; " -} - -func SanitizeFolderPath(folderPath string) string { - - normalizedPath := strings.ReplaceAll(folderPath, "/", string(filepath.Separator)) - - sep := string(filepath.Separator) - - parts := strings.Split(normalizedPath, sep) - sanitizedParts := make([]string, 0, len(parts)) - - for i, part := range parts { - - if i == 0 && len(part) == 2 && part[1] == ':' { - sanitizedParts = append(sanitizedParts, part) - continue - } - - if i == 0 && part == "" { - sanitizedParts = append(sanitizedParts, part) - continue - } - - sanitized := sanitizeFolderName(part) - if sanitized != "" { - sanitizedParts = append(sanitizedParts, sanitized) - } - } - - return strings.Join(sanitizedParts, sep) -} - -func sanitizeFolderName(name string) string { return SanitizeFilename(name) } - -func sanitizeFilename(name string) string { - return SanitizeFilename(name) -} - -func SanitizeOptionalFilename(name string) string { - if strings.TrimSpace(name) == "" { - return "" - } - return SanitizeFilename(name) -} diff --git a/backend/folder.go b/backend/folder.go deleted file mode 100644 index db33d2a..0000000 --- a/backend/folder.go +++ /dev/null @@ -1,115 +0,0 @@ -package backend - -import ( - "context" - "os/exec" - "runtime" - - wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime" -) - -func OpenFolderInExplorer(path string) error { - var cmd *exec.Cmd - - switch runtime.GOOS { - case "windows": - cmd = exec.Command("explorer", path) - case "darwin": - cmd = exec.Command("open", path) - case "linux": - cmd = exec.Command("xdg-open", path) - default: - cmd = exec.Command("xdg-open", path) - } - - return cmd.Start() -} - -func SelectFolderDialog(ctx context.Context, defaultPath string) (string, error) { - - if defaultPath == "" { - defaultPath = GetDefaultMusicPath() - } - - options := wailsRuntime.OpenDialogOptions{ - Title: "Select Download Folder", - DefaultDirectory: defaultPath, - } - - selectedPath, err := wailsRuntime.OpenDirectoryDialog(ctx, options) - if err != nil { - return "", err - } - - if selectedPath == "" { - return "", nil - } - - return selectedPath, nil -} - -func SelectFileDialog(ctx context.Context) (string, error) { - options := wailsRuntime.OpenDialogOptions{ - Title: "Select Audio File for Analysis", - Filters: []wailsRuntime.FileFilter{ - { - DisplayName: "Audio Files (*.flac;*.mp3;*.m4a;*.aac)", - Pattern: "*.flac;*.mp3;*.m4a;*.aac", - }, - { - DisplayName: "FLAC Audio Files (*.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 (*.*)", - Pattern: "*.*", - }, - }, - } - - selectedFile, err := wailsRuntime.OpenFileDialog(ctx, options) - if err != nil { - return "", err - } - - if selectedFile == "" { - return "", nil - } - - return selectedFile, nil -} - -func SelectImageVideoDialog(ctx context.Context) ([]string, error) { - options := wailsRuntime.OpenDialogOptions{ - Title: "Select Image or Video", - Filters: []wailsRuntime.FileFilter{ - { - DisplayName: "Supported Files (*.jpg, *.png, *.mp4, *.mov, ...)", - Pattern: "*.jpg;*.jpeg;*.png;*.gif;*.webp;*.mp4;*.mkv;*.webm;*.mov", - }, - { - DisplayName: "All Files (*.*)", - Pattern: "*.*", - }, - }, - } - - selectedPaths, err := wailsRuntime.OpenMultipleFilesDialog(ctx, options) - if err != nil { - return nil, err - } - - return selectedPaths, nil -} diff --git a/backend/history.go b/backend/history.go deleted file mode 100644 index 18804c0..0000000 --- a/backend/history.go +++ /dev/null @@ -1,323 +0,0 @@ -package backend - -import ( - "encoding/json" - "fmt" - "path/filepath" - "sort" - "time" - - bolt "go.etcd.io/bbolt" -) - -type HistoryItem struct { - ID string `json:"id"` - SpotifyID string `json:"spotify_id"` - Title string `json:"title"` - Artists string `json:"artists"` - Album string `json:"album"` - DurationStr string `json:"duration_str"` - CoverURL string `json:"cover_url"` - Quality string `json:"quality"` - Format string `json:"format"` - Path string `json:"path"` - Source string `json:"source"` - Timestamp int64 `json:"timestamp"` -} - -var historyDB *bolt.DB - -const ( - historyBucket = "DownloadHistory" - maxHistory = 10000 -) - -func InitHistoryDB(appName string) error { - - appDir, err := EnsureAppDir() - if err != nil { - return err - } - dbPath := filepath.Join(appDir, "history.db") - - db, err := bolt.Open(dbPath, 0600, &bolt.Options{Timeout: 1 * time.Second}) - if err != nil { - return err - } - - err = db.Update(func(tx *bolt.Tx) error { - _, err := tx.CreateBucketIfNotExists([]byte(historyBucket)) - return err - }) - - if err != nil { - db.Close() - return err - } - - historyDB = db - return nil -} - -func CloseHistoryDB() { - if historyDB != nil { - historyDB.Close() - } -} - -func AddHistoryItem(item HistoryItem, appName string) error { - if historyDB == nil { - if err := InitHistoryDB(appName); err != nil { - return err - } - } - return historyDB.Update(func(tx *bolt.Tx) error { - b, err := tx.CreateBucketIfNotExists([]byte(historyBucket)) - if err != nil { - return err - } - id, _ := b.NextSequence() - - item.ID = fmt.Sprintf("%d-%d", time.Now().UnixNano(), id) - item.Timestamp = time.Now().Unix() - - buf, err := json.Marshal(item) - if err != nil { - return err - } - - if b.Stats().KeyN >= maxHistory { - c := b.Cursor() - - toDelete := maxHistory / 20 - if toDelete < 1 { - toDelete = 1 - } - - count := 0 - for k, _ := c.First(); k != nil && count < toDelete; k, _ = c.Next() { - if err := b.Delete(k); err != nil { - return err - } - count++ - } - } - - return b.Put([]byte(item.ID), buf) - }) -} - -func GetHistoryItems(appName string) ([]HistoryItem, error) { - if historyDB == nil { - if err := InitHistoryDB(appName); err != nil { - return nil, err - } - } - var items []HistoryItem - err := historyDB.View(func(tx *bolt.Tx) error { - b := tx.Bucket([]byte(historyBucket)) - if b == nil { - return nil - } - c := b.Cursor() - - for k, v := c.First(); k != nil; k, v = c.Next() { - var item HistoryItem - if err := json.Unmarshal(v, &item); err == nil { - items = append(items, item) - } - } - return nil - }) - - sort.Slice(items, func(i, j int) bool { - return items[i].Timestamp > items[j].Timestamp - }) - - return items, err -} - -func ClearHistory(appName string) error { - if historyDB == nil { - if err := InitHistoryDB(appName); err != nil { - return err - } - } - return historyDB.Update(func(tx *bolt.Tx) error { - return tx.DeleteBucket([]byte(historyBucket)) - }) -} - -type FetchHistoryItem struct { - ID string `json:"id"` - URL string `json:"url"` - Type string `json:"type"` - Name string `json:"name"` - Info string `json:"info"` - Image string `json:"image"` - Data string `json:"data"` - Timestamp int64 `json:"timestamp"` -} - -const ( - fetchHistoryBucket = "FetchHistory" -) - -func AddFetchHistoryItem(item FetchHistoryItem, appName string) error { - if historyDB == nil { - if err := InitHistoryDB(appName); err != nil { - return err - } - } - return historyDB.Update(func(tx *bolt.Tx) error { - b, err := tx.CreateBucketIfNotExists([]byte(fetchHistoryBucket)) - if err != nil { - return err - } - id, _ := b.NextSequence() - - if item.URL != "" { - c := b.Cursor() - for k, v := c.First(); k != nil; k, v = c.Next() { - var existing FetchHistoryItem - if err := json.Unmarshal(v, &existing); err == nil { - if existing.URL == item.URL && existing.Type == item.Type { - if err := b.Delete(k); err != nil { - return err - } - } - } - } - } - - item.ID = fmt.Sprintf("%d-%d", time.Now().UnixNano(), id) - item.Timestamp = time.Now().Unix() - - buf, err := json.Marshal(item) - if err != nil { - return err - } - - if b.Stats().KeyN >= maxHistory { - c := b.Cursor() - toDelete := maxHistory / 20 - if toDelete < 1 { - toDelete = 1 - } - count := 0 - for k, _ := c.First(); k != nil && count < toDelete; k, _ = c.Next() { - if err := b.Delete(k); err != nil { - return err - } - count++ - } - } - - return b.Put([]byte(item.ID), buf) - }) -} - -func GetFetchHistoryItems(appName string) ([]FetchHistoryItem, error) { - if historyDB == nil { - if err := InitHistoryDB(appName); err != nil { - return nil, err - } - } - var items []FetchHistoryItem - err := historyDB.View(func(tx *bolt.Tx) error { - b := tx.Bucket([]byte(fetchHistoryBucket)) - if b == nil { - return nil - } - c := b.Cursor() - - for k, v := c.First(); k != nil; k, v = c.Next() { - var item FetchHistoryItem - if err := json.Unmarshal(v, &item); err == nil { - items = append(items, item) - } - } - return nil - }) - - sort.Slice(items, func(i, j int) bool { - return items[i].Timestamp > items[j].Timestamp - }) - - return items, err -} - -func ClearFetchHistory(appName string) error { - if historyDB == nil { - if err := InitHistoryDB(appName); err != nil { - return err - } - } - return historyDB.Update(func(tx *bolt.Tx) error { - return tx.DeleteBucket([]byte(fetchHistoryBucket)) - }) -} - -func ClearFetchHistoryByType(itemType string, appName string) error { - if historyDB == nil { - if err := InitHistoryDB(appName); err != nil { - return err - } - } - return historyDB.Update(func(tx *bolt.Tx) error { - b := tx.Bucket([]byte(fetchHistoryBucket)) - if b == nil { - return nil - } - - var keysToDelete [][]byte - - c := b.Cursor() - for k, v := c.First(); k != nil; k, v = c.Next() { - var item FetchHistoryItem - if err := json.Unmarshal(v, &item); err == nil { - if item.Type == itemType { - keysToDelete = append(keysToDelete, k) - } - } - } - - for _, k := range keysToDelete { - if err := b.Delete(k); err != nil { - return err - } - } - return nil - }) -} - -func DeleteHistoryItem(id string, appName string) error { - if historyDB == nil { - if err := InitHistoryDB(appName); err != nil { - return err - } - } - return historyDB.Update(func(tx *bolt.Tx) error { - b := tx.Bucket([]byte(historyBucket)) - if b == nil { - return nil - } - - return b.Delete([]byte(id)) - }) -} - -func DeleteFetchHistoryItem(id string, appName string) error { - if historyDB == nil { - if err := InitHistoryDB(appName); err != nil { - return err - } - } - return historyDB.Update(func(tx *bolt.Tx) error { - b := tx.Bucket([]byte(fetchHistoryBucket)) - if b == nil { - return nil - } - return b.Delete([]byte(id)) - }) -} diff --git a/backend/http_headers.go b/backend/http_headers.go deleted file mode 100644 index 4d6a042..0000000 --- a/backend/http_headers.go +++ /dev/null @@ -1,20 +0,0 @@ -package backend - -import ( - "io" - "net/http" -) - -const DefaultDownloaderUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36" - -func NewRequestWithDefaultHeaders(method string, rawURL string, body io.Reader) (*http.Request, error) { - req, err := http.NewRequest(method, rawURL, body) - if err != nil { - return nil, err - } - - req.Header.Set("User-Agent", DefaultDownloaderUserAgent) - req.Header.Set("Accept", "application/json, text/plain, */*") - - return req, nil -} diff --git a/backend/isrc_cache.go b/backend/isrc_cache.go deleted file mode 100644 index cbe6b34..0000000 --- a/backend/isrc_cache.go +++ /dev/null @@ -1,137 +0,0 @@ -package backend - -import ( - "encoding/json" - "fmt" - "path/filepath" - "strings" - "sync" - "time" - - bolt "go.etcd.io/bbolt" -) - -const ( - isrcCacheDBFile = "isrc_cache.db" - isrcCacheBucket = "SpotifyTrackISRC" -) - -type isrcCacheEntry struct { - TrackID string `json:"track_id"` - ISRC string `json:"isrc"` - UpdatedAt int64 `json:"updated_at"` -} - -var ( - isrcCacheDB *bolt.DB - isrcCacheDBMu sync.Mutex -) - -func InitISRCCacheDB() error { - isrcCacheDBMu.Lock() - defer isrcCacheDBMu.Unlock() - - if isrcCacheDB != nil { - return nil - } - - appDir, err := EnsureAppDir() - if err != nil { - return err - } - - dbPath := filepath.Join(appDir, isrcCacheDBFile) - db, err := bolt.Open(dbPath, 0o600, &bolt.Options{Timeout: 1 * time.Second}) - if err != nil { - return err - } - - if err := db.Update(func(tx *bolt.Tx) error { - _, err := tx.CreateBucketIfNotExists([]byte(isrcCacheBucket)) - return err - }); err != nil { - db.Close() - return err - } - - isrcCacheDB = db - return nil -} - -func CloseISRCCacheDB() { - isrcCacheDBMu.Lock() - defer isrcCacheDBMu.Unlock() - - if isrcCacheDB != nil { - _ = isrcCacheDB.Close() - isrcCacheDB = nil - } -} - -func GetCachedISRC(trackID string) (string, error) { - normalizedTrackID := strings.TrimSpace(trackID) - if normalizedTrackID == "" { - return "", nil - } - - if err := InitISRCCacheDB(); err != nil { - return "", err - } - - var cachedISRC string - err := isrcCacheDB.View(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(isrcCacheBucket)) - if bucket == nil { - return nil - } - - value := bucket.Get([]byte(normalizedTrackID)) - if len(value) == 0 { - return nil - } - - var entry isrcCacheEntry - if err := json.Unmarshal(value, &entry); err != nil { - return err - } - - cachedISRC = strings.ToUpper(strings.TrimSpace(entry.ISRC)) - return nil - }) - if err != nil { - return "", err - } - - return cachedISRC, nil -} - -func PutCachedISRC(trackID string, isrc string) error { - normalizedTrackID := strings.TrimSpace(trackID) - normalizedISRC := strings.ToUpper(strings.TrimSpace(isrc)) - if normalizedTrackID == "" || normalizedISRC == "" { - return nil - } - - if err := InitISRCCacheDB(); err != nil { - return err - } - - entry := isrcCacheEntry{ - TrackID: normalizedTrackID, - ISRC: normalizedISRC, - UpdatedAt: time.Now().Unix(), - } - - payload, err := json.Marshal(entry) - if err != nil { - return fmt.Errorf("failed to encode ISRC cache entry: %w", err) - } - - return isrcCacheDB.Update(func(tx *bolt.Tx) error { - bucket, err := tx.CreateBucketIfNotExists([]byte(isrcCacheBucket)) - if err != nil { - return err - } - return bucket.Put([]byte(normalizedTrackID), payload) - }) -} diff --git a/backend/isrc_finder.go b/backend/isrc_finder.go deleted file mode 100644 index 4796c37..0000000 --- a/backend/isrc_finder.go +++ /dev/null @@ -1,464 +0,0 @@ -package backend - -import ( - "encoding/json" - "errors" - "fmt" - "io" - "math/big" - "net/http" - "net/url" - "os" - "path/filepath" - "strconv" - "strings" - "sync" - "time" -) - -const ( - spotifySessionTokenURL = "https://open.spotify.com/api/token" - spotifyGIDMetadataURL = "https://spclient.wg.spotify.com/metadata/4/%s/%s?market=from_token" - spotifyBase62Alphabet = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" - spotifyTokenCacheFile = ".isrc-finder-token.json" -) - -var spotifyAnonymousTokenMu sync.Mutex - -type spotifyAnonymousToken struct { - AccessToken string `json:"accessToken"` - AccessTokenExpirationTimestampMs int64 `json:"accessTokenExpirationTimestampMs"` -} - -type spotifyTrackRawData struct { - Album struct { - GID string `json:"gid"` - } `json:"album"` - ExternalID []struct { - Type string `json:"type"` - ID string `json:"id"` - } `json:"external_id"` -} - -type spotifyAlbumRawData struct { - ExternalID []struct { - Type string `json:"type"` - ID string `json:"id"` - } `json:"external_id"` -} - -type SpotifyTrackIdentifiers struct { - ISRC string `json:"isrc,omitempty"` - UPC string `json:"upc,omitempty"` -} - -func GetSpotifyTrackIdentifiersDirect(spotifyTrackID string) (SpotifyTrackIdentifiers, error) { - normalizedTrackID, err := extractSpotifyTrackID(spotifyTrackID) - if err != nil { - return SpotifyTrackIdentifiers{}, err - } - - identifiers := SpotifyTrackIdentifiers{} - - cachedISRC, err := GetCachedISRC(normalizedTrackID) - if err != nil { - fmt.Printf("Warning: failed to read ISRC cache: %v\n", err) - } else if cachedISRC != "" { - fmt.Printf("Found ISRC in cache: %s\n", cachedISRC) - identifiers.ISRC = cachedISRC - } - - httpClient := &http.Client{Timeout: 30 * time.Second} - - payload, metadataErr := fetchSpotifyTrackRawData(httpClient, normalizedTrackID) - if metadataErr == nil { - metadataIdentifiers, extractErr := extractSpotifyTrackIdentifiers(httpClient, payload) - if extractErr == nil { - mergeSpotifyTrackIdentifiers(&identifiers, metadataIdentifiers) - if identifiers.ISRC != "" { - fmt.Printf("Found identifiers via Spotify metadata: isrc=%s upc=%s\n", identifiers.ISRC, identifiers.UPC) - cacheResolvedSpotifyTrackISRC(normalizedTrackID, "", identifiers.ISRC) - } - if identifiers.ISRC != "" && identifiers.UPC != "" { - return identifiers, nil - } - } - metadataErr = extractErr - } - - if metadataErr != nil { - fmt.Printf("Warning: Spotify metadata identifier lookup failed, falling back to Soundplate: %v\n", metadataErr) - } - - if identifiers.ISRC == "" { - client := NewSongLinkClient() - isrc, resolvedTrackID, soundplateErr := client.lookupSpotifyISRCViaSoundplate(normalizedTrackID) - if soundplateErr == nil && isrc != "" { - identifiers.ISRC = isrc - fmt.Printf("Found ISRC via Soundplate: %s\n", isrc) - cacheResolvedSpotifyTrackISRC(normalizedTrackID, resolvedTrackID, isrc) - return identifiers, nil - } - - if metadataErr != nil && soundplateErr != nil { - return identifiers, fmt.Errorf("spotify metadata lookup failed: %v | soundplate lookup failed: %w", metadataErr, soundplateErr) - } - if soundplateErr != nil && identifiers.UPC == "" { - return identifiers, soundplateErr - } - } - - if identifiers.ISRC != "" || identifiers.UPC != "" { - return identifiers, nil - } - if metadataErr != nil { - return identifiers, metadataErr - } - - return identifiers, fmt.Errorf("no Spotify identifiers found for track %s", normalizedTrackID) -} - -func (s *SongLinkClient) lookupSpotifyISRC(spotifyTrackID string) (string, error) { - identifiers, err := GetSpotifyTrackIdentifiersDirect(spotifyTrackID) - if err != nil { - return "", err - } - if identifiers.ISRC == "" { - return "", fmt.Errorf("no Spotify ISRC found for track %s", strings.TrimSpace(spotifyTrackID)) - } - - return identifiers.ISRC, nil -} - -func cacheResolvedSpotifyTrackISRC(trackID string, resolvedTrackID string, isrc string) { - if err := PutCachedISRC(trackID, isrc); err != nil { - fmt.Printf("Warning: failed to write ISRC cache: %v\n", err) - } - if resolvedTrackID != "" && resolvedTrackID != trackID { - if err := PutCachedISRC(resolvedTrackID, isrc); err != nil { - fmt.Printf("Warning: failed to write ISRC cache for resolved track ID: %v\n", err) - } - } -} - -func mergeSpotifyTrackIdentifiers(target *SpotifyTrackIdentifiers, incoming SpotifyTrackIdentifiers) { - if incoming.ISRC != "" { - target.ISRC = strings.TrimSpace(incoming.ISRC) - } - if incoming.UPC != "" { - target.UPC = strings.TrimSpace(incoming.UPC) - } -} - -func lookupSpotifyAlbumUPC(albumID string) (string, error) { - normalizedAlbumID := strings.TrimSpace(albumID) - if normalizedAlbumID == "" { - return "", fmt.Errorf("spotify album ID is required") - } - - httpClient := &http.Client{Timeout: 30 * time.Second} - payload, err := fetchSpotifyAlbumRawData(httpClient, normalizedAlbumID) - if err != nil { - return "", err - } - - return extractSpotifyAlbumUPC(payload) -} - -func requestSpotifyBytes(client *http.Client, targetURL string, headers map[string]string) ([]byte, error) { - req, err := http.NewRequest(http.MethodGet, targetURL, nil) - if err != nil { - return nil, err - } - - for key, value := range headers { - req.Header.Set(key, value) - } - - resp, err := client.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - - if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { - details := strings.TrimSpace(string(body)) - if details == "" { - details = resp.Status - } - return nil, fmt.Errorf("request failed: %s", details) - } - - return body, nil -} - -func requestSpotifyJSON(client *http.Client, targetURL string, headers map[string]string, target interface{}) error { - body, err := requestSpotifyBytes(client, targetURL, headers) - if err != nil { - return err - } - - if err := json.Unmarshal(body, target); err != nil { - return fmt.Errorf("failed to parse JSON response: %w", err) - } - - return nil -} - -func loadSpotifyCachedToken() (*spotifyAnonymousToken, error) { - cachePath, err := spotifyTokenCachePath() - if err != nil { - return nil, err - } - - body, err := os.ReadFile(cachePath) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - return nil, nil - } - return nil, fmt.Errorf("failed to read token cache: %w", err) - } - - var token spotifyAnonymousToken - if err := json.Unmarshal(body, &token); err != nil { - return nil, fmt.Errorf("failed to read token cache: %w", err) - } - - return &token, nil -} - -func saveSpotifyCachedToken(token *spotifyAnonymousToken) error { - cachePath, err := spotifyTokenCachePath() - if err != nil { - return err - } - - if err := os.MkdirAll(filepath.Dir(cachePath), 0o755); err != nil { - return fmt.Errorf("failed to create token cache directory: %w", err) - } - - body, err := json.MarshalIndent(token, "", " ") - if err != nil { - return err - } - - if err := os.WriteFile(cachePath, body, 0o644); err != nil { - return fmt.Errorf("failed to write token cache: %w", err) - } - - return nil -} - -func spotifyTokenCachePath() (string, error) { - appDir, err := EnsureAppDir() - if err != nil { - return "", err - } - - return filepath.Join(appDir, spotifyTokenCacheFile), nil -} - -func spotifyTokenIsValid(token *spotifyAnonymousToken) bool { - if token == nil || token.AccessToken == "" || token.AccessTokenExpirationTimestampMs == 0 { - return false - } - - return time.Now().UnixMilli() < token.AccessTokenExpirationTimestampMs-30_000 -} - -func requestSpotifyAnonymousAccessToken(client *http.Client) (string, error) { - spotifyAnonymousTokenMu.Lock() - defer spotifyAnonymousTokenMu.Unlock() - - cachedToken, err := loadSpotifyCachedToken() - if err != nil { - return "", err - } - - if spotifyTokenIsValid(cachedToken) { - return cachedToken.AccessToken, nil - } - - generatedTOTP, version, err := generateSpotifyTOTP(time.Now()) - if err != nil { - return "", fmt.Errorf("failed to generate Spotify TOTP: %w", err) - } - - query := url.Values{ - "reason": {"init"}, - "productType": {"web-player"}, - "totp": {generatedTOTP}, - "totpServer": {generatedTOTP}, - "totpVer": {strconv.Itoa(version)}, - } - - var token spotifyAnonymousToken - if err := requestSpotifyJSON(client, spotifySessionTokenURL+"?"+query.Encode(), nil, &token); err != nil { - return "", err - } - - if err := saveSpotifyCachedToken(&token); err != nil { - return "", err - } - - return token.AccessToken, nil -} - -func extractSpotifyTrackID(value string) (string, error) { - value = strings.TrimSpace(value) - if value == "" { - return "", errors.New("track input is required") - } - - if strings.HasPrefix(value, "spotify:track:") { - return value[strings.LastIndex(value, ":")+1:], nil - } - - parsed, err := url.Parse(value) - if err == nil && (parsed.Scheme == "http" || parsed.Scheme == "https") { - parts := strings.Split(strings.Trim(parsed.Path, "/"), "/") - if len(parts) >= 2 && parts[0] == "track" { - return parts[1], nil - } - return "", errors.New("expected URL like https://open.spotify.com/track/") - } - - if len(value) == 22 { - return value, nil - } - - return "", errors.New("track must be a Spotify track ID, URL, or URI") -} - -func spotifyTrackIDToGID(trackID string) (string, error) { - return spotifyEntityIDToGID(trackID) -} - -func spotifyEntityIDToGID(entityID string) (string, error) { - if entityID == "" { - return "", errors.New("entity ID is empty") - } - - value := big.NewInt(0) - base := big.NewInt(62) - - for _, char := range entityID { - index := strings.IndexRune(spotifyBase62Alphabet, char) - if index < 0 { - return "", fmt.Errorf("invalid base62 character: %q", string(char)) - } - - value.Mul(value, base) - value.Add(value, big.NewInt(int64(index))) - } - - hexValue := value.Text(16) - if len(hexValue) < 32 { - hexValue = strings.Repeat("0", 32-len(hexValue)) + hexValue - } - - return hexValue, nil -} - -func fetchSpotifyTrackRawData(client *http.Client, trackID string) ([]byte, error) { - gid, err := spotifyTrackIDToGID(trackID) - if err != nil { - return nil, err - } - - return fetchSpotifyRawMetadataByGID(client, "track", gid) -} - -func fetchSpotifyAlbumRawData(client *http.Client, albumID string) ([]byte, error) { - gid, err := spotifyEntityIDToGID(albumID) - if err != nil { - return nil, err - } - - return fetchSpotifyRawMetadataByGID(client, "album", gid) -} - -func fetchSpotifyRawMetadataByGID(client *http.Client, entityType string, gid string) ([]byte, error) { - accessToken, err := requestSpotifyAnonymousAccessToken(client) - if err != nil { - return nil, err - } - - return requestSpotifyBytes( - client, - fmt.Sprintf(spotifyGIDMetadataURL, entityType, gid), - map[string]string{ - "authorization": "Bearer " + accessToken, - "accept": "application/json", - "user-agent": songLinkUserAgent, - }, - ) -} - -func extractSpotifyTrackIdentifiers(client *http.Client, payload []byte) (SpotifyTrackIdentifiers, error) { - var track spotifyTrackRawData - if err := json.Unmarshal(payload, &track); err != nil { - return SpotifyTrackIdentifiers{}, fmt.Errorf("failed to decode Spotify track metadata: %w", err) - } - - identifiers := SpotifyTrackIdentifiers{} - for _, externalID := range track.ExternalID { - if strings.EqualFold(strings.TrimSpace(externalID.Type), "isrc") { - if isrc := firstISRCMatch(externalID.ID); isrc != "" { - identifiers.ISRC = isrc - break - } - } - } - - if identifiers.ISRC == "" { - identifiers.ISRC = firstISRCMatch(string(payload)) - } - - albumGID := strings.TrimSpace(track.Album.GID) - if client != nil && albumGID != "" { - albumPayload, err := fetchSpotifyRawMetadataByGID(client, "album", albumGID) - if err == nil { - if upc, upcErr := extractSpotifyAlbumUPC(albumPayload); upcErr == nil { - identifiers.UPC = upc - } - } - } - - return identifiers, nil -} - -func extractSpotifyTrackISRC(payload []byte) (string, error) { - identifiers, err := extractSpotifyTrackIdentifiers(nil, payload) - if err != nil { - return "", err - } - if identifiers.ISRC != "" { - return identifiers.ISRC, nil - } - - return "", fmt.Errorf("ISRC not found in Spotify track metadata") -} - -func extractSpotifyAlbumUPC(payload []byte) (string, error) { - var album spotifyAlbumRawData - if err := json.Unmarshal(payload, &album); err != nil { - return "", fmt.Errorf("failed to decode Spotify album metadata: %w", err) - } - - for _, externalID := range album.ExternalID { - if strings.EqualFold(strings.TrimSpace(externalID.Type), "upc") { - upc := strings.TrimSpace(externalID.ID) - if upc != "" { - return upc, nil - } - } - } - - return "", fmt.Errorf("UPC not found in Spotify album metadata") -} diff --git a/backend/isrc_helper.go b/backend/isrc_helper.go deleted file mode 100644 index 2f8283b..0000000 --- a/backend/isrc_helper.go +++ /dev/null @@ -1,22 +0,0 @@ -package backend - -import "strings" - -func ResolveTrackISRC(spotifyTrackID string) string { - spotifyTrackID = strings.TrimSpace(spotifyTrackID) - if spotifyTrackID == "" { - return "" - } - - if cachedISRC, err := GetCachedISRC(spotifyTrackID); err == nil && cachedISRC != "" { - return strings.ToUpper(strings.TrimSpace(cachedISRC)) - } - - client := NewSongLinkClient() - isrc, err := client.GetISRCDirect(spotifyTrackID) - if err != nil { - return "" - } - - return strings.ToUpper(strings.TrimSpace(isrc)) -} diff --git a/backend/link_resolver.go b/backend/link_resolver.go deleted file mode 100644 index 2c23573..0000000 --- a/backend/link_resolver.go +++ /dev/null @@ -1,154 +0,0 @@ -package backend - -import ( - "errors" - "fmt" - "strings" -) - -type resolvedTrackLinks struct { - TidalURL string - AmazonURL string - DeezerURL string - ISRC string -} - -const ( - linkResolverProviderSongstats = "songstats" - linkResolverProviderDeezerSongLink = "deezer-songlink" -) - -func (s *SongLinkClient) resolveSpotifyTrackLinks(spotifyTrackID string, region string) (*resolvedTrackLinks, error) { - links := &resolvedTrackLinks{} - var attempts []string - - isrc, err := s.lookupSpotifyISRC(spotifyTrackID) - if err != nil { - attempts = append(attempts, fmt.Sprintf("spotify isrc: %v", err)) - } else { - links.ISRC = isrc - } - - if links.ISRC != "" { - resolvers := orderedLinkResolvers() - - for _, resolver := range resolvers { - switch resolver { - case linkResolverProviderSongstats: - addedData, songstatsErr := s.resolveLinksViaSongstats(links) - if songstatsErr != nil { - attempts = append(attempts, fmt.Sprintf("songstats: %v", songstatsErr)) - } else if addedData { - fmt.Println("Using Songstats as configured link resolver") - } - case linkResolverProviderDeezerSongLink: - addedData, deezerSongLinkErr := s.resolveLinksViaDeezerSongLink(links, region) - if deezerSongLinkErr != nil { - attempts = append(attempts, fmt.Sprintf("deezer-songlink: %v", deezerSongLinkErr)) - } else if addedData { - fmt.Println("Using Songlink as configured link resolver") - } - } - - if links.TidalURL != "" && links.AmazonURL != "" { - return links, nil - } - } - } - - if hasAnySongLinkData(links) { - return links, nil - } - - if len(attempts) == 0 { - attempts = append(attempts, "no streaming URLs found") - } - - return links, errors.New(strings.Join(attempts, " | ")) -} - -func orderedLinkResolvers() []string { - preferred := GetLinkResolverSetting() - if !GetLinkResolverAllowFallback() { - if preferred == linkResolverProviderDeezerSongLink { - return []string{linkResolverProviderDeezerSongLink} - } - return []string{linkResolverProviderSongstats} - } - - if preferred == linkResolverProviderDeezerSongLink { - return []string{ - linkResolverProviderDeezerSongLink, - linkResolverProviderSongstats, - } - } - - return []string{ - linkResolverProviderSongstats, - linkResolverProviderDeezerSongLink, - } -} - -func (s *SongLinkClient) resolveLinksViaSongstats(links *resolvedTrackLinks) (bool, error) { - if links == nil || links.ISRC == "" { - return false, fmt.Errorf("ISRC is required for Songstats resolver") - } - - before := *links - - fmt.Printf("Fetching Songstats links for ISRC %s\n", links.ISRC) - if err := s.populateLinksFromSongstats(links, links.ISRC); err != nil { - return false, err - } - - return *links != before, nil -} - -func (s *SongLinkClient) resolveLinksViaDeezerSongLink(links *resolvedTrackLinks, region string) (bool, error) { - if links == nil || links.ISRC == "" { - return false, fmt.Errorf("ISRC is required for Deezer song.link resolver") - } - - before := *links - var attempts []string - - if links.DeezerURL == "" { - fmt.Printf("Resolving Deezer track from ISRC %s\n", links.ISRC) - deezerURL, err := s.lookupDeezerTrackURLByISRC(links.ISRC) - if err != nil { - attempts = append(attempts, fmt.Sprintf("deezer isrc: %v", err)) - } else { - links.DeezerURL = deezerURL - fmt.Printf("Found Deezer URL: %s\n", links.DeezerURL) - } - } - - if links.DeezerURL != "" { - fmt.Println("Resolving streaming URLs from song.link via Deezer URL...") - deezerResp, err := s.fetchSongLinkLinksByURL(links.DeezerURL, region) - if err != nil { - attempts = append(attempts, fmt.Sprintf("song.link deezer: %v", err)) - } else { - mergeSongLinkResponse(links, deezerResp) - } - - if links.ISRC == "" { - if resolvedISRC, deezerISRCErr := getDeezerISRC(links.DeezerURL); deezerISRCErr == nil { - links.ISRC = resolvedISRC - } - } - } - - if *links != before { - if len(attempts) == 0 { - return true, nil - } - return true, errors.New(strings.Join(attempts, " | ")) - } - - if len(attempts) == 0 { - attempts = append(attempts, "no links found via deezer-songlink") - } - - return false, errors.New(strings.Join(attempts, " | ")) -} diff --git a/backend/lyrics.go b/backend/lyrics.go deleted file mode 100644 index 16e025a..0000000 --- a/backend/lyrics.go +++ /dev/null @@ -1,540 +0,0 @@ -package backend - -import ( - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "os" - "path/filepath" - "regexp" - "strings" - "time" -) - -type LRCLibResponse struct { - ID int `json:"id"` - Name string `json:"name"` - TrackName string `json:"trackName"` - ArtistName string `json:"artistName"` - AlbumName string `json:"albumName"` - Duration float64 `json:"duration"` - Instrumental bool `json:"instrumental"` - PlainLyrics string `json:"plainLyrics"` - SyncedLyrics string `json:"syncedLyrics"` -} - -type LyricsLine struct { - StartTimeMs string `json:"startTimeMs"` - Words string `json:"words"` - EndTimeMs string `json:"endTimeMs"` -} - -type LyricsResponse struct { - Error bool `json:"error"` - SyncType string `json:"syncType"` - Lines []LyricsLine `json:"lines"` -} - -type LyricsDownloadRequest struct { - SpotifyID string `json:"spotify_id"` - TrackName string `json:"track_name"` - ArtistName string `json:"artist_name"` - AlbumName string `json:"album_name"` - AlbumArtist string `json:"album_artist"` - ReleaseDate string `json:"release_date"` - ISRC string `json:"isrc"` - OutputDir string `json:"output_dir"` - FilenameFormat string `json:"filename_format"` - TrackNumber bool `json:"track_number"` - Position int `json:"position"` - UseAlbumTrackNumber bool `json:"use_album_track_number"` - DiscNumber int `json:"disc_number"` -} - -type LyricsDownloadResponse struct { - Success bool `json:"success"` - Message string `json:"message"` - File string `json:"file,omitempty"` - Error string `json:"error,omitempty"` - AlreadyExists bool `json:"already_exists,omitempty"` -} - -type LyricsClient struct { - httpClient *http.Client -} - -func NewLyricsClient() *LyricsClient { - return &LyricsClient{ - httpClient: &http.Client{Timeout: 15 * time.Second}, - } -} - -func (c *LyricsClient) FetchLyricsWithMetadata(trackName, artistName, albumName string, duration int) (*LyricsResponse, error) { - - apiURL := fmt.Sprintf("https://lrclib.net/api/get?artist_name=%s&track_name=%s", - url.QueryEscape(artistName), - url.QueryEscape(trackName)) - - if albumName != "" { - apiURL = fmt.Sprintf("%s&album_name=%s", apiURL, url.QueryEscape(albumName)) - } - - if duration > 0 { - apiURL = fmt.Sprintf("%s&duration=%d", apiURL, duration) - } - - resp, err := c.httpClient.Get(apiURL) - if err != nil { - return nil, fmt.Errorf("failed to fetch from LRCLIB: %v", err) - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - return nil, fmt.Errorf("LRCLIB returned status %d", resp.StatusCode) - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read LRCLIB response: %v", err) - } - - var lrcLibResp LRCLibResponse - if err := json.Unmarshal(body, &lrcLibResp); err != nil { - 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 -} - -func (c *LyricsClient) convertLRCLibToLyricsResponse(lrcLib *LRCLibResponse) *LyricsResponse { - resp := &LyricsResponse{ - Error: false, - SyncType: "LINE_SYNCED", - Lines: []LyricsLine{}, - } - - lyricsText := lrcLib.SyncedLyrics - if lyricsText == "" { - lyricsText = lrcLib.PlainLyrics - resp.SyncType = "UNSYNCED" - } - - if lyricsText == "" { - resp.Error = true - return resp - } - - lines := strings.Split(lyricsText, "\n") - for _, line := range lines { - line = strings.TrimSpace(line) - if line == "" { - continue - } - - if strings.HasPrefix(line, "[") && len(line) > 10 { - closeBracket := strings.Index(line, "]") - if closeBracket > 0 { - timestamp := line[1:closeBracket] - words := strings.TrimSpace(line[closeBracket+1:]) - - ms := lrcTimestampToMs(timestamp) - resp.Lines = append(resp.Lines, LyricsLine{ - StartTimeMs: fmt.Sprintf("%d", ms), - Words: words, - }) - continue - } - } - - resp.Lines = append(resp.Lines, LyricsLine{ - StartTimeMs: "", - Words: line, - }) - } - - return resp -} - -func lrcTimestampToMs(timestamp string) int64 { - var minutes, seconds, centiseconds int64 - - n, _ := fmt.Sscanf(timestamp, "%d:%d.%d", &minutes, &seconds, ¢iseconds) - if n >= 2 { - return minutes*60*1000 + seconds*1000 + centiseconds*10 - } - return 0 -} - -func (c *LyricsClient) FetchLyricsFromLRCLibSearch(trackName, artistName string) (*LyricsResponse, error) { - - apiURL := fmt.Sprintf("https://lrclib.net/api/search?artist_name=%s&track_name=%s", - url.QueryEscape(artistName), - url.QueryEscape(trackName)) - - resp, err := c.httpClient.Get(apiURL) - if err != nil { - return nil, fmt.Errorf("request failed: %v", err) - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - return nil, fmt.Errorf("status %d", resp.StatusCode) - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("read failed: %v", err) - } - - var results []LRCLibResponse - if err := json.Unmarshal(body, &results); err != nil { - return nil, fmt.Errorf("parse failed: %v", err) - } - - if len(results) == 0 { - return nil, fmt.Errorf("no results found") - } - - var bestSynced *LRCLibResponse - var bestPlain *LRCLibResponse - for i := range results { - if results[i].SyncedLyrics != "" && bestSynced == nil { - bestSynced = &results[i] - } - if results[i].PlainLyrics != "" && bestPlain == nil { - bestPlain = &results[i] - } - if bestSynced != nil { - break - } - } - - best := bestSynced - if best == nil { - best = bestPlain - } - if best == nil { - best = &results[0] - } - - if best.SyncedLyrics == "" && best.PlainLyrics == "" { - return nil, fmt.Errorf("no lyrics found in search results") - } - - return c.convertLRCLibToLyricsResponse(best), nil -} - -func simplifyTrackName(name string) string { - - if idx := strings.Index(name, "("); idx > 0 { - name = strings.TrimSpace(name[:idx]) - } - - if idx := strings.Index(name, " - "); idx > 0 { - name = strings.TrimSpace(name[:idx]) - } - return name -} - -func isSynced(resp *LyricsResponse) bool { - return resp != nil && !resp.Error && resp.SyncType == "LINE_SYNCED" && len(resp.Lines) > 0 -} - -func hasLyrics(resp *LyricsResponse) bool { - return resp != nil && !resp.Error && len(resp.Lines) > 0 -} - -func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName, albumName string, duration int) (*LyricsResponse, string, error) { - - 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 - } - - 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) - if simplifiedTrack != trackName { - fmt.Printf(" Trying simplified name: %s\n", simplifiedTrack) - - resp, _ = c.FetchLyricsWithMetadata(simplifiedTrack, artistName, albumName, duration) - resp, src, found = check(resp, nil, "LRCLIB (simplified)") - if found { - fmt.Printf(" [LRCLIB] Synced found via simplified exact\n") - return resp, src, nil - } - - resp, _ = c.FetchLyricsFromLRCLibSearch(simplifiedTrack, artistName) - resp, src, found = check(resp, nil, "LRCLIB Search (simplified)") - 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") -} - -func (c *LyricsClient) ConvertToLRC(lyrics *LyricsResponse, trackName, artistName string) string { - var sb strings.Builder - - sb.WriteString(fmt.Sprintf("[ti:%s]\n", trackName)) - sb.WriteString(fmt.Sprintf("[ar:%s]\n", artistName)) - sb.WriteString("[by:SpotiFlac]\n") - sb.WriteString("\n") - - for _, line := range lyrics.Lines { - if line.Words == "" { - continue - } - - if line.StartTimeMs == "" { - sb.WriteString(fmt.Sprintf("%s\n", line.Words)) - } else { - - timestamp := msToLRCTimestamp(line.StartTimeMs) - sb.WriteString(fmt.Sprintf("%s%s\n", timestamp, line.Words)) - } - } - - return sb.String() -} - -func msToLRCTimestamp(msStr string) string { - var ms int64 - fmt.Sscanf(msStr, "%d", &ms) - - totalSeconds := ms / 1000 - minutes := totalSeconds / 60 - seconds := totalSeconds % 60 - centiseconds := (ms % 1000) / 10 - - return fmt.Sprintf("[%02d:%02d.%02d]", minutes, seconds, centiseconds) -} - -func buildLyricsFilename(trackName, artistName, albumName, albumArtist, releaseDate, filenameFormat, isrc string, includeTrackNumber bool, position, discNumber int) string { - safeTitle := sanitizeFilename(trackName) - safeArtist := sanitizeFilename(artistName) - safeAlbum := sanitizeFilename(albumName) - safeAlbumArtist := sanitizeFilename(albumArtist) - safeISRC := SanitizeOptionalFilename(isrc) - - year := "" - if len(releaseDate) >= 4 { - year = releaseDate[:4] - } - - var filename string - - if strings.Contains(filenameFormat, "{") { - filename = filenameFormat - filename = strings.ReplaceAll(filename, "{title}", safeTitle) - filename = strings.ReplaceAll(filename, "{artist}", safeArtist) - filename = strings.ReplaceAll(filename, "{album}", safeAlbum) - filename = strings.ReplaceAll(filename, "{album_artist}", safeAlbumArtist) - filename = strings.ReplaceAll(filename, "{year}", year) - filename = strings.ReplaceAll(filename, "{date}", sanitizeFilename(releaseDate)) - filename = strings.ReplaceAll(filename, "{isrc}", safeISRC) - - if discNumber > 0 { - filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber)) - } else { - filename = strings.ReplaceAll(filename, "{disc}", "") - } - - if position > 0 { - filename = strings.ReplaceAll(filename, "{track}", fmt.Sprintf("%02d", position)) - } else { - - filename = regexp.MustCompile(`\{track\}\.\s*`).ReplaceAllString(filename, "") - filename = regexp.MustCompile(`\{track\}\s*-\s*`).ReplaceAllString(filename, "") - filename = regexp.MustCompile(`\{track\}\s*`).ReplaceAllString(filename, "") - } - } else { - - switch filenameFormat { - case "artist-title": - filename = fmt.Sprintf("%s - %s", safeArtist, safeTitle) - case "title": - filename = safeTitle - default: - filename = fmt.Sprintf("%s - %s", safeTitle, safeArtist) - } - - if includeTrackNumber && position > 0 { - filename = fmt.Sprintf("%02d. %s", position, filename) - } - } - - return filename + ".lrc" -} - -func findAudioFileForLyrics(dir, trackName, artistName string) string { - - safeTitle := sanitizeFilename(trackName) - safeArtist := sanitizeFilename(artistName) - - audioExts := []string{".flac", ".mp3", ".m4a", ".FLAC", ".MP3", ".M4A"} - - patterns := []string{ - fmt.Sprintf("%s - %s", safeTitle, safeArtist), - fmt.Sprintf("%s - %s", safeArtist, safeTitle), - safeTitle, - } - - entries, err := os.ReadDir(dir) - if err != nil { - return "" - } - - for _, entry := range entries { - if entry.IsDir() { - continue - } - - filename := entry.Name() - baseName := strings.TrimSuffix(filename, filepath.Ext(filename)) - - for _, pattern := range patterns { - if strings.HasPrefix(baseName, pattern) || strings.Contains(baseName, pattern) { - ext := strings.ToLower(filepath.Ext(filename)) - for _, audioExt := range audioExts { - if ext == strings.ToLower(audioExt) { - return filepath.Join(dir, filename) - } - } - } - } - } - - return "" -} - -func (c *LyricsClient) DownloadLyrics(req LyricsDownloadRequest) (*LyricsDownloadResponse, error) { - if req.SpotifyID == "" { - return &LyricsDownloadResponse{ - Success: false, - Error: "Spotify ID is required", - }, fmt.Errorf("spotify ID is required") - } - - outputDir := req.OutputDir - if outputDir == "" { - outputDir = GetDefaultMusicPath() - } else { - outputDir = NormalizePath(outputDir) - } - - if err := os.MkdirAll(outputDir, 0755); err != nil { - return &LyricsDownloadResponse{ - Success: false, - Error: fmt.Sprintf("failed to create output directory: %v", err), - }, err - } - - filenameFormat := req.FilenameFormat - if filenameFormat == "" { - filenameFormat = "title-artist" - } - resolvedISRC := strings.TrimSpace(req.ISRC) - if resolvedISRC == "" && strings.Contains(filenameFormat, "{isrc}") { - resolvedISRC = ResolveTrackISRC(req.SpotifyID) - } - filename := buildLyricsFilename(req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, filenameFormat, resolvedISRC, req.TrackNumber, req.Position, req.DiscNumber) - filePath := filepath.Join(outputDir, filename) - - filePath, alreadyExists := ResolveOutputPathForDownload(filePath, GetRedownloadWithSuffixSetting()) - if alreadyExists { - return &LyricsDownloadResponse{ - Success: true, - Message: "Lyrics file already exists", - File: filePath, - AlreadyExists: true, - }, nil - } - - audioDuration := 0 - audioFile := findAudioFileForLyrics(outputDir, req.TrackName, req.ArtistName) - if audioFile != "" { - duration, err := GetAudioDuration(audioFile) - if err == nil && duration > 0 { - audioDuration = int(duration) - fmt.Printf("[DownloadLyrics] Found audio file, duration: %d seconds\n", audioDuration) - } - } - - lyrics, _, err := c.FetchLyricsAllSources(req.SpotifyID, req.TrackName, req.ArtistName, req.AlbumName, audioDuration) - if err != nil { - return &LyricsDownloadResponse{ - Success: false, - Error: err.Error(), - }, err - } - - lrcContent := c.ConvertToLRC(lyrics, req.TrackName, req.ArtistName) - - if err := os.WriteFile(filePath, []byte(lrcContent), 0644); err != nil { - return &LyricsDownloadResponse{ - Success: false, - Error: fmt.Sprintf("failed to write LRC file: %v", err), - }, err - } - - return &LyricsDownloadResponse{ - Success: true, - Message: "Lyrics downloaded successfully", - File: filePath, - }, nil -} diff --git a/backend/metadata.go b/backend/metadata.go deleted file mode 100644 index f438338..0000000 --- a/backend/metadata.go +++ /dev/null @@ -1,1255 +0,0 @@ -package backend - -import ( - "encoding/json" - "fmt" - "os" - "os/exec" - pathfilepath "path/filepath" - "strconv" - "strings" - - id3v2 "github.com/bogem/id3v2/v2" - "github.com/go-flac/flacpicture" - "github.com/go-flac/flacvorbis" - "github.com/go-flac/go-flac" - "golang.org/x/text/unicode/norm" -) - -type Metadata struct { - Title string - Artist string - Album string - AlbumArtist string - Separator string - Date string - ReleaseDate string - TrackNumber int - TotalTracks int - DiscNumber int - TotalDiscs int - URL string - Comment string - Copyright string - Publisher string - Composer string - Lyrics string - Description string - ISRC string - UPC string - Genre string -} - -func resolveMetadataSeparator(separator string) string { - if normalized := normalizeArtistSeparator(separator); normalized != "" { - return normalized - } - - return normalizeArtistSeparator(GetSeparator()) -} - -func displayMetadataSeparator(separator string) string { - if resolved := resolveMetadataSeparator(separator); resolved != "" { - return resolved + " " - } - - return "; " -} - -func addVorbisTagValues(cmt *flacvorbis.MetaDataBlockVorbisComment, key string, values []string) { - for _, value := range values { - value = strings.TrimSpace(value) - if value == "" { - continue - } - - _ = cmt.Add(key, value) - } -} - -func addMP3TextFrame(tag *id3v2.Tag, frameID string, value string) { - tag.DeleteFrames(frameID) - value = strings.TrimSpace(value) - if value == "" { - return - } - - tag.AddTextFrame(frameID, id3v2.EncodingUTF8, value) -} - -func joinMultiValueText(values []string, separator string, nullSeparated bool) string { - cleaned := make([]string, 0, len(values)) - for _, value := range values { - value = strings.TrimSpace(value) - if value != "" { - cleaned = append(cleaned, value) - } - } - - if len(cleaned) == 0 { - return "" - } - if len(cleaned) == 1 { - return cleaned[0] - } - if nullSeparated { - return strings.Join(cleaned, "\x00") - } - - return strings.Join(cleaned, displayMetadataSeparator(separator)) -} - -func EmbedMetadata(filepath string, metadata Metadata, coverPath string) error { - f, err := flac.ParseFile(filepath) - if err != nil { - return fmt.Errorf("failed to parse FLAC file: %w", err) - } - - var cmtIdx = -1 - for idx, block := range f.Meta { - if block.Type == flac.VorbisComment { - cmtIdx = idx - break - } - } - - cmt := flacvorbis.New() - separator := resolveMetadataSeparator(metadata.Separator) - - if metadata.Title != "" { - _ = cmt.Add(flacvorbis.FIELD_TITLE, metadata.Title) - } - if artistValues := SplitArtistCredits(metadata.Artist, separator); len(artistValues) > 0 { - addVorbisTagValues(cmt, flacvorbis.FIELD_ARTIST, artistValues) - } else if metadata.Artist != "" { - _ = cmt.Add(flacvorbis.FIELD_ARTIST, metadata.Artist) - } - if metadata.Album != "" { - _ = cmt.Add(flacvorbis.FIELD_ALBUM, metadata.Album) - } - if albumArtistValues := SplitArtistCredits(metadata.AlbumArtist, separator); len(albumArtistValues) > 0 { - addVorbisTagValues(cmt, "ALBUMARTIST", albumArtistValues) - } else if metadata.AlbumArtist != "" { - _ = cmt.Add("ALBUMARTIST", metadata.AlbumArtist) - } - if metadata.Date != "" { - _ = cmt.Add(flacvorbis.FIELD_DATE, metadata.Date) - } - if metadata.TrackNumber > 0 { - _ = cmt.Add(flacvorbis.FIELD_TRACKNUMBER, strconv.Itoa(metadata.TrackNumber)) - } - if metadata.TotalTracks > 0 { - _ = cmt.Add("TOTALTRACKS", strconv.Itoa(metadata.TotalTracks)) - } - if metadata.DiscNumber > 0 { - _ = cmt.Add("DISCNUMBER", strconv.Itoa(metadata.DiscNumber)) - } - if metadata.TotalDiscs > 0 { - _ = cmt.Add("TOTALDISCS", strconv.Itoa(metadata.TotalDiscs)) - } - if metadata.Copyright != "" { - _ = cmt.Add("COPYRIGHT", metadata.Copyright) - } - if metadata.Publisher != "" { - _ = cmt.Add("PUBLISHER", metadata.Publisher) - } - if composerValues := SplitArtistCredits(metadata.Composer, separator); len(composerValues) > 0 { - addVorbisTagValues(cmt, "COMPOSER", composerValues) - } else if metadata.Composer != "" { - _ = cmt.Add("COMPOSER", metadata.Composer) - } - if metadata.Description != "" { - _ = cmt.Add("DESCRIPTION", metadata.Description) - } - if comment := resolveMetadataComment(metadata); comment != "" { - _ = cmt.Add("COMMENT", comment) - } - - if metadata.ISRC != "" { - _ = cmt.Add("ISRC", metadata.ISRC) - } - if metadata.UPC != "" { - _ = cmt.Add(preferredUPCTagKey, metadata.UPC) - } - - if genreValues := SplitMetadataValues(metadata.Genre, separator); len(genreValues) > 0 { - addVorbisTagValues(cmt, "GENRE", genreValues) - } else if metadata.Genre != "" { - _ = cmt.Add("GENRE", metadata.Genre) - } - - if metadata.Lyrics != "" { - _ = cmt.Add("LYRICS", metadata.Lyrics) - } - - cmtBlock := cmt.Marshal() - if cmtIdx < 0 { - f.Meta = append(f.Meta, &cmtBlock) - } else { - f.Meta[cmtIdx] = &cmtBlock - } - - if coverPath != "" && fileExists(coverPath) { - if err := embedCoverArt(f, coverPath); err != nil { - fmt.Printf("Warning: Failed to embed cover art: %v\n", err) - } - } - - if err := f.Save(filepath); err != nil { - return fmt.Errorf("failed to save FLAC file: %w", err) - } - - return nil -} - -func embedCoverArt(f *flac.File, coverPath string) error { - imgData, err := os.ReadFile(coverPath) - if err != nil { - return fmt.Errorf("failed to read cover image: %w", err) - } - - picture, err := flacpicture.NewFromImageData( - flacpicture.PictureTypeFrontCover, - "Cover", - imgData, - "image/jpeg", - ) - if err != nil { - return fmt.Errorf("failed to create picture block: %w", err) - } - - pictureBlock := picture.Marshal() - - for i := len(f.Meta) - 1; i >= 0; i-- { - if f.Meta[i].Type == flac.Picture { - f.Meta = append(f.Meta[:i], f.Meta[i+1:]...) - } - } - - f.Meta = append(f.Meta, &pictureBlock) - - return nil -} - -func fileExists(path string) bool { - _, err := os.Stat(path) - return err == nil -} - -func extractYear(releaseDate string) string { - if releaseDate == "" { - return "" - } - - if len(releaseDate) >= 4 { - return releaseDate[:4] - } - return releaseDate -} - -func resolveMetadataComment(metadata Metadata) string { - if comment := strings.TrimSpace(metadata.Comment); comment != "" { - return comment - } - - return strings.TrimSpace(metadata.URL) -} - -func EmbedLyricsOnly(filepath string, lyrics string) error { - if lyrics == "" { - return nil - } - f, err := flac.ParseFile(filepath) - if err != nil { - return fmt.Errorf("failed to parse FLAC file: %w", err) - } - - var cmtIdx = -1 - var existingCmt *flacvorbis.MetaDataBlockVorbisComment - for idx, block := range f.Meta { - if block.Type == flac.VorbisComment { - cmtIdx = idx - existingCmt, err = flacvorbis.ParseFromMetaDataBlock(*block) - if err != nil { - existingCmt = nil - } - break - } - } - - cmt := flacvorbis.New() - - if existingCmt != nil { - for _, comment := range existingCmt.Comments { - parts := strings.SplitN(comment, "=", 2) - if len(parts) == 2 { - fieldName := strings.ToUpper(parts[0]) - if fieldName != "LYRICS" && fieldName != "UNSYNCEDLYRICS" && fieldName != "SYNCEDLYRICS" { - _ = cmt.Add(parts[0], parts[1]) - } - } - } - } - - _ = cmt.Add("LYRICS", lyrics) - - cmtBlock := cmt.Marshal() - if cmtIdx < 0 { - f.Meta = append(f.Meta, &cmtBlock) - } else { - f.Meta[cmtIdx] = &cmtBlock - } - - if err := f.Save(filepath); err != nil { - return fmt.Errorf("failed to save FLAC file: %w", err) - } - - return nil -} - -func ExtractCoverArt(filePath string) (string, error) { - filePath = norm.NFC.String(filePath) - ext := strings.ToLower(pathfilepath.Ext(filePath)) - - var coverPath string - var err error - - switch ext { - case ".mp3": - coverPath, err = extractCoverFromMp3(filePath) - case ".m4a", ".flac": - coverPath, err = extractCoverFromM4AOrFlac(filePath) - default: - 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) { - tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true}) - if err != nil { - return "", fmt.Errorf("failed to open MP3 file: %w", err) - } - defer tag.Close() - - pictures := tag.GetFrames(tag.CommonID("Attached picture")) - if len(pictures) == 0 { - return "", fmt.Errorf("no cover art found") - } - - pic, ok := pictures[0].(id3v2.PictureFrame) - if !ok { - return "", fmt.Errorf("invalid picture frame") - } - - tmpFile, err := os.CreateTemp("", "cover-*.jpg") - if err != nil { - return "", fmt.Errorf("failed to create temp file: %w", err) - } - defer tmpFile.Close() - - if _, err := tmpFile.Write(pic.Picture); err != nil { - os.Remove(tmpFile.Name()) - return "", fmt.Errorf("failed to write cover art: %w", err) - } - - return tmpFile.Name(), nil -} - -func extractCoverFromM4AOrFlac(filePath string) (string, error) { - ext := strings.ToLower(pathfilepath.Ext(filePath)) - - if ext == ".flac" { - f, err := flac.ParseFile(filePath) - if err != nil { - return "", fmt.Errorf("failed to parse FLAC file: %w", err) - } - - for _, block := range f.Meta { - if block.Type == flac.Picture { - pic, err := flacpicture.ParseFromMetaDataBlock(*block) - if err != nil { - continue - } - - tmpFile, err := os.CreateTemp("", "cover-*.jpg") - if err != nil { - return "", fmt.Errorf("failed to create temp file: %w", err) - } - defer tmpFile.Close() - - if _, err := tmpFile.Write(pic.ImageData); err != nil { - os.Remove(tmpFile.Name()) - return "", fmt.Errorf("failed to write cover art: %w", err) - } - - return tmpFile.Name(), nil - } - } - return "", fmt.Errorf("no cover art found") - } - - return "", nil -} - -func ExtractLyrics(filePath string) (string, error) { - filePath = norm.NFC.String(filePath) - ext := strings.ToLower(pathfilepath.Ext(filePath)) - - var lyrics string - var err error - - switch ext { - case ".mp3": - lyrics, err = extractLyricsFromMp3(filePath) - case ".flac": - lyrics, err = extractLyricsFromFlac(filePath) - case ".m4a": - return "", nil - default: - 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) { - tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true}) - if err != nil { - return "", fmt.Errorf("failed to open MP3 file: %w", err) - } - defer tag.Close() - - usltFrames := tag.GetFrames(tag.CommonID("Unsynchronised lyrics/text transcription")) - if len(usltFrames) == 0 { - fmt.Printf("[ExtractLyrics] No USLT frames found in MP3: %s\n", filePath) - return "", nil - } - - uslt, ok := usltFrames[0].(id3v2.UnsynchronisedLyricsFrame) - if !ok { - fmt.Printf("[ExtractLyrics] USLT frame type assertion failed in MP3: %s\n", filePath) - return "", nil - } - - if uslt.Lyrics == "" { - fmt.Printf("[ExtractLyrics] USLT frame has empty lyrics in MP3: %s\n", filePath) - return "", nil - } - - fmt.Printf("[ExtractLyrics] Successfully extracted lyrics from MP3: %s (%d characters)\n", filePath, len(uslt.Lyrics)) - return uslt.Lyrics, nil -} - -func extractLyricsFromFlac(filePath string) (string, error) { - f, err := flac.ParseFile(filePath) - if err != nil { - return "", fmt.Errorf("failed to parse FLAC file: %w", err) - } - - for _, block := range f.Meta { - if block.Type == flac.VorbisComment { - cmt, err := flacvorbis.ParseFromMetaDataBlock(*block) - if err != nil { - continue - } - - for _, comment := range cmt.Comments { - parts := strings.SplitN(comment, "=", 2) - if len(parts) == 2 { - fieldName := strings.ToUpper(parts[0]) - if fieldName == "LYRICS" || fieldName == "UNSYNCEDLYRICS" { - lyrics := parts[1] - fmt.Printf("[ExtractLyrics] Successfully extracted lyrics from FLAC: %s (%d characters)\n", filePath, len(lyrics)) - return lyrics, nil - } - } - } - } - } - - fmt.Printf("[ExtractLyrics] No lyrics found in FLAC: %s\n", filePath) - return "", nil -} - -func EmbedCoverArtOnly(filePath string, coverPath string) error { - if coverPath == "" || !fileExists(coverPath) { - return nil - } - - ext := strings.ToLower(pathfilepath.Ext(filePath)) - - switch ext { - case ".mp3": - return embedCoverToMp3(filePath, coverPath) - case ".m4a": - - return nil - default: - return fmt.Errorf("unsupported file format: %s", ext) - } -} - -func embedCoverToMp3(filePath string, coverPath string) error { - tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true}) - if err != nil { - return fmt.Errorf("failed to open MP3 file: %w", err) - } - defer tag.Close() - - tag.DeleteFrames(tag.CommonID("Attached picture")) - - artwork, err := os.ReadFile(coverPath) - if err != nil { - return fmt.Errorf("failed to read cover art: %w", err) - } - - pic := id3v2.PictureFrame{ - Encoding: id3v2.EncodingUTF8, - MimeType: "image/jpeg", - PictureType: id3v2.PTFrontCover, - Description: "Front cover", - Picture: artwork, - } - tag.AddAttachedPicture(pic) - - if err := tag.Save(); err != nil { - return fmt.Errorf("failed to save MP3 tags: %w", err) - } - - return nil -} - -func EmbedLyricsOnlyMP3(filepath string, lyrics string) error { - if lyrics == "" { - return nil - } - - validatedLyrics, err := validateLyricsDuration(lyrics, filepath) - if err != nil { - fmt.Printf("[EmbedLyricsOnlyMP3] Warning: Failed to validate lyrics duration: %v, using original lyrics\n", err) - validatedLyrics = lyrics - } - lyrics = validatedLyrics - - tag, err := id3v2.Open(filepath, id3v2.Options{Parse: true}) - if err != nil { - return fmt.Errorf("failed to open MP3 file: %w", err) - } - defer tag.Close() - - tag.DeleteFrames(tag.CommonID("Unsynchronised lyrics/text transcription")) - - usltFrame := id3v2.UnsynchronisedLyricsFrame{ - Encoding: id3v2.EncodingUTF8, - Language: "eng", - ContentDescriptor: "", - Lyrics: lyrics, - } - tag.AddUnsynchronisedLyricsFrame(usltFrame) - - if err := tag.Save(); err != nil { - return fmt.Errorf("failed to save MP3 tags: %w", err) - } - - return nil -} - -func embedLyricsToM4A(filepath string, lyrics string) error { - - validatedLyrics, err := validateLyricsDuration(lyrics, filepath) - if err != nil { - fmt.Printf("[embedLyricsToM4A] Warning: Failed to validate lyrics duration: %v, using original lyrics\n", err) - validatedLyrics = lyrics - } - lyrics = validatedLyrics - - ffmpegPath, err := GetFFmpegPath() - if err != nil { - return fmt.Errorf("ffmpeg not found: %w", err) - } - - if err := ValidateExecutable(ffmpegPath); err != nil { - return fmt.Errorf("invalid ffmpeg executable: %w", err) - } - - tmpOutputFile := strings.TrimSuffix(filepath, pathfilepath.Ext(filepath)) + ".tmp" + pathfilepath.Ext(filepath) - defer func() { - - if _, err := os.Stat(tmpOutputFile); err == nil { - os.Remove(tmpOutputFile) - } - }() - - cmd := exec.Command(ffmpegPath, - "-i", filepath, - "-map", "0", - "-map_metadata", "0", - "-metadata", "lyrics-eng="+lyrics, - "-metadata", "lyrics="+lyrics, - "-codec", "copy", - "-f", "ipod", - "-y", - tmpOutputFile, - ) - - setHideWindow(cmd) - - output, err := cmd.CombinedOutput() - if err != nil { - fmt.Printf("[FFmpeg] Error embedding lyrics to M4A: %s\n", string(output)) - return fmt.Errorf("ffmpeg failed to embed lyrics: %s - %w", string(output), err) - } - - if err := os.Rename(tmpOutputFile, filepath); err != nil { - return fmt.Errorf("failed to replace original file: %w", err) - } - - fmt.Printf("[FFmpeg] Lyrics embedded to M4A successfully: %d characters\n", len(lyrics)) - return nil -} - -func EmbedLyricsOnlyUniversal(filepath string, lyrics string) error { - if lyrics == "" { - 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)) - switch ext { - case ".mp3": - return EmbedLyricsOnlyMP3(filepath, lyrics) - case ".flac": - return EmbedLyricsOnly(filepath, lyrics) - case ".m4a": - return embedLyricsToM4A(filepath, lyrics) - default: - return fmt.Errorf("unsupported file format for lyrics embedding: %s", ext) - } -} - -func GetAudioDuration(filepath string) (float64, error) { - ext := strings.ToLower(pathfilepath.Ext(filepath)) - - if ext == ".flac" { - duration, err := getFlacDuration(filepath) - if err == nil && duration > 0 { - return duration, nil - } - } - - return getDurationWithFFprobe(filepath) -} - -func getFlacDuration(filepath string) (float64, error) { - f, err := flac.ParseFile(filepath) - if err != nil { - return 0, err - } - - if len(f.Meta) > 0 { - streamInfo := f.Meta[0] - if streamInfo.Type == flac.StreamInfo { - data := streamInfo.Data - if len(data) >= 18 { - - sampleRate := uint32(data[10])<<12 | uint32(data[11])<<4 | uint32(data[12])>>4 - - totalSamples := uint64(data[13]&0x0F)<<32 | - uint64(data[14])<<24 | - uint64(data[15])<<16 | - uint64(data[16])<<8 | - uint64(data[17]) - - if sampleRate > 0 { - return float64(totalSamples) / float64(sampleRate), nil - } - } - } - } - - return 0, fmt.Errorf("could not extract duration from FLAC file") -} - -func getDurationWithFFprobe(filepath string) (float64, error) { - ffprobePath, err := GetFFprobePath() - if err != nil { - return 0, err - } - - if err := ValidateExecutable(ffprobePath); err != nil { - return 0, fmt.Errorf("invalid ffprobe executable: %w", err) - } - - cmd := exec.Command(ffprobePath, - "-v", "quiet", - "-print_format", "json", - "-show_format", - filepath, - ) - - setHideWindow(cmd) - - output, err := cmd.Output() - if err != nil { - return 0, err - } - - var result struct { - Format struct { - Duration string `json:"duration"` - } `json:"format"` - } - - if err := json.Unmarshal(output, &result); err != nil { - return 0, err - } - - if result.Format.Duration == "" { - return 0, fmt.Errorf("duration not found in ffprobe output") - } - - duration, err := strconv.ParseFloat(result.Format.Duration, 64) - if err != nil { - return 0, err - } - - return duration, nil -} - -func validateLyricsDuration(lyrics string, filepath string) (string, error) { - - duration, err := GetAudioDuration(filepath) - if err != nil { - - fmt.Printf("[ValidateLyrics] Warning: Could not get audio duration: %v, skipping validation\n", err) - return lyrics, nil - } - - if duration <= 0 { - - fmt.Printf("[ValidateLyrics] Warning: Invalid duration (%f seconds), skipping validation\n", duration) - return lyrics, nil - } - - durationMs := int64(duration * 1000) - - lines := strings.Split(lyrics, "\n") - var validLines []string - - for _, line := range lines { - trimmedLine := strings.TrimSpace(line) - if trimmedLine == "" { - validLines = append(validLines, line) - continue - } - - if strings.HasPrefix(trimmedLine, "[") { - - closeBracket := strings.Index(trimmedLine, "]") - if closeBracket > 0 { - timestampStr := trimmedLine[1:closeBracket] - - ms := parseLRCTimestamp(timestampStr) - if ms >= 0 { - if ms <= durationMs { - validLines = append(validLines, line) - } else { - fmt.Printf("[ValidateLyrics] Filtered out line with timestamp %s (exceeds duration %d ms): %s\n", timestampStr, durationMs, trimmedLine) - } - } else { - - validLines = append(validLines, line) - } - continue - } - } else { - - validLines = append(validLines, line) - } - } - - return strings.Join(validLines, "\n"), nil -} - -func parseLRCTimestamp(timestamp string) int64 { - var minutes, seconds, centiseconds int64 - n, _ := fmt.Sscanf(timestamp, "%d:%d.%d", &minutes, &seconds, ¢iseconds) - if n >= 2 { - return minutes*60*1000 + seconds*1000 + centiseconds*10 - } - return -1 -} - -func ExtractFullMetadataFromFile(filePath string) (Metadata, error) { - filePath = norm.NFC.String(filePath) - var metadata Metadata - - ffprobePath, err := GetFFprobePath() - if err != nil { - return metadata, err - } - - if err := ValidateExecutable(ffprobePath); err != nil { - return metadata, fmt.Errorf("invalid ffprobe executable: %w", err) - } - - cmd := exec.Command(ffprobePath, - "-v", "quiet", - "-print_format", "json", - "-show_format", - "-show_streams", - filePath, - ) - - setHideWindow(cmd) - - output, err := cmd.Output() - if err != nil { - return metadata, err - } - - var result struct { - Format struct { - Tags map[string]string `json:"tags"` - } `json:"format"` - Streams []struct { - Tags map[string]string `json:"tags"` - } `json:"streams"` - } - - if err := json.Unmarshal(output, &result); err != nil { - return metadata, err - } - - allTags := make(map[string]string) - - for _, stream := range result.Streams { - for key, value := range stream.Tags { - allTags[strings.ToLower(key)] = value - } - } - - for key, value := range result.Format.Tags { - allTags[strings.ToLower(key)] = value - } - - for key, value := range allTags { - switch key { - case "title": - metadata.Title = value - case "artist": - metadata.Artist = value - case "album": - metadata.Album = value - case "album_artist", "albumartist": - metadata.AlbumArtist = value - case "date", "year": - if metadata.Date == "" || len(value) > len(metadata.Date) { - metadata.Date = value - } - case "track": - - parts := strings.Split(value, "/") - if len(parts) > 0 { - if num, err := strconv.Atoi(parts[0]); err == nil { - metadata.TrackNumber = num - } - } - if len(parts) > 1 { - if num, err := strconv.Atoi(parts[1]); err == nil { - metadata.TotalTracks = num - } - } - case "disc": - - parts := strings.Split(value, "/") - if len(parts) > 0 { - if num, err := strconv.Atoi(parts[0]); err == nil { - metadata.DiscNumber = num - } - } - if len(parts) > 1 { - if num, err := strconv.Atoi(parts[1]); err == nil { - metadata.TotalDiscs = num - } - } - case "copyright", "tcop": - metadata.Copyright = value - case "publisher", "tpub", "label": - metadata.Publisher = value - case "composer", "writer", "wm/composer", "©wrt": - metadata.Composer = value - case "genre", "tcon": - metadata.Genre = value - case "url": - metadata.URL = value - case "isrc", "tsrc": - metadata.ISRC = value - case "comment", "comments": - if metadata.Comment == "" { - metadata.Comment = value - } - case "description": - if metadata.Description == "" { - metadata.Description = value - } - } - } - - metadata.UPC = firstPreferredFFprobeUPCValue(allTags) - - return metadata, nil -} - -func EmbedMetadataToConvertedFile(filePath string, metadata Metadata, coverPath string) error { - filePath = norm.NFC.String(filePath) - ext := strings.ToLower(pathfilepath.Ext(filePath)) - - switch ext { - case ".flac": - - return EmbedMetadata(filePath, metadata, coverPath) - case ".mp3": - return embedMetadataToMP3(filePath, metadata, coverPath) - case ".m4a": - return embedMetadataToM4A(filePath, metadata, coverPath) - default: - return fmt.Errorf("unsupported file format: %s", ext) - } -} - -func embedMetadataToMP3(filePath string, metadata Metadata, coverPath string) error { - tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true}) - if err != nil { - return fmt.Errorf("failed to open MP3 file: %w", err) - } - defer tag.Close() - separator := resolveMetadataSeparator(metadata.Separator) - - tag.DeleteFrames("TXXX") - - if metadata.Title != "" { - tag.SetTitle(metadata.Title) - } - if metadata.Album != "" { - tag.SetAlbum(metadata.Album) - } - if metadata.Date != "" { - year := metadata.Date - if len(year) >= 4 { - year = year[:4] - } - tag.SetYear(year) - } - - artistText := joinMultiValueText(SplitArtistCredits(metadata.Artist, separator), separator, true) - if artistText == "" { - artistText = strings.TrimSpace(metadata.Artist) - } - addMP3TextFrame(tag, "TPE1", artistText) - - albumArtistText := joinMultiValueText(SplitArtistCredits(metadata.AlbumArtist, separator), separator, true) - if albumArtistText == "" { - albumArtistText = strings.TrimSpace(metadata.AlbumArtist) - } - addMP3TextFrame(tag, "TPE2", albumArtistText) - - if metadata.TrackNumber > 0 { - tag.DeleteFrames(tag.CommonID("Track number/Position in set")) - trackStr := strconv.Itoa(metadata.TrackNumber) - if metadata.TotalTracks > 0 { - trackStr = fmt.Sprintf("%d/%d", metadata.TrackNumber, metadata.TotalTracks) - } - tag.AddTextFrame(tag.CommonID("Track number/Position in set"), id3v2.EncodingUTF8, trackStr) - } - - if metadata.DiscNumber > 0 { - tag.DeleteFrames(tag.CommonID("Part of a set")) - discStr := strconv.Itoa(metadata.DiscNumber) - if metadata.TotalDiscs > 0 { - discStr = fmt.Sprintf("%d/%d", metadata.DiscNumber, metadata.TotalDiscs) - } - tag.AddTextFrame(tag.CommonID("Part of a set"), id3v2.EncodingUTF8, discStr) - } - - if metadata.Copyright != "" { - addMP3TextFrame(tag, "TCOP", metadata.Copyright) - } - - if metadata.Publisher != "" { - addMP3TextFrame(tag, "TPUB", metadata.Publisher) - } - - composerText := joinMultiValueText(SplitArtistCredits(metadata.Composer, separator), separator, true) - if composerText == "" { - composerText = strings.TrimSpace(metadata.Composer) - } - addMP3TextFrame(tag, "TCOM", composerText) - - if metadata.ISRC != "" { - addMP3TextFrame(tag, "TSRC", metadata.ISRC) - } - if metadata.UPC != "" { - tag.AddUserDefinedTextFrame(id3v2.UserDefinedTextFrame{ - Encoding: id3v2.EncodingUTF8, - Description: "UPC", - Value: metadata.UPC, - }) - } - - if comment := resolveMetadataComment(metadata); comment != "" { - tag.DeleteFrames(tag.CommonID("Comments")) - tag.AddCommentFrame(id3v2.CommentFrame{ - Encoding: id3v2.EncodingUTF8, - Language: "eng", - Description: "", - Text: comment, - }) - } - - if coverPath != "" && fileExists(coverPath) { - - tag.DeleteFrames(tag.CommonID("Attached picture")) - - artwork, err := os.ReadFile(coverPath) - if err == nil { - pic := id3v2.PictureFrame{ - Encoding: id3v2.EncodingUTF8, - MimeType: "image/jpeg", - PictureType: id3v2.PTFrontCover, - Description: "Cover", - Picture: artwork, - } - tag.AddAttachedPicture(pic) - } else { - fmt.Printf("[EmbedMetadataToMP3] Warning: Failed to read cover art file: %v\n", err) - } - } - - genreText := joinMultiValueText(SplitMetadataValues(metadata.Genre, separator), separator, true) - if genreText == "" { - genreText = strings.TrimSpace(metadata.Genre) - } - addMP3TextFrame(tag, "TCON", genreText) - - if err := tag.Save(); err != nil { - return fmt.Errorf("failed to save MP3 tags: %w", err) - } - - return nil -} - -func embedMetadataToM4A(filePath string, metadata Metadata, coverPath string) error { - ffmpegPath, err := GetFFmpegPath() - if err != nil { - return fmt.Errorf("ffmpeg not found: %w", err) - } - - if err := ValidateExecutable(ffmpegPath); err != nil { - return fmt.Errorf("invalid ffmpeg executable: %w", err) - } - - args := []string{ - "-i", filePath, - "-y", - } - separator := resolveMetadataSeparator(metadata.Separator) - - if coverPath != "" && fileExists(coverPath) { - args = append(args, "-i", coverPath) - args = append(args, "-map", "0:a", "-map", "1", "-c:a", "copy", "-c:v", "copy", "-disposition:v:0", "attached_pic") - } else { - args = append(args, "-map", "0", "-codec", "copy") - } - - if metadata.Title != "" { - args = append(args, "-metadata", "title="+metadata.Title) - } - artistText := joinMultiValueText(SplitArtistCredits(metadata.Artist, separator), separator, false) - if artistText == "" { - artistText = strings.TrimSpace(metadata.Artist) - } - if artistText != "" { - args = append(args, "-metadata", "artist="+artistText) - } - if metadata.Album != "" { - args = append(args, "-metadata", "album="+metadata.Album) - } - albumArtistText := joinMultiValueText(SplitArtistCredits(metadata.AlbumArtist, separator), separator, false) - if albumArtistText == "" { - albumArtistText = strings.TrimSpace(metadata.AlbumArtist) - } - if albumArtistText != "" { - args = append(args, "-metadata", "album_artist="+albumArtistText) - } - if metadata.Date != "" { - args = append(args, "-metadata", "date="+metadata.Date) - } - if metadata.TrackNumber > 0 { - trackStr := strconv.Itoa(metadata.TrackNumber) - if metadata.TotalTracks > 0 { - trackStr = fmt.Sprintf("%d/%d", metadata.TrackNumber, metadata.TotalTracks) - } - args = append(args, "-metadata", "track="+trackStr) - } - if metadata.DiscNumber > 0 { - discStr := strconv.Itoa(metadata.DiscNumber) - if metadata.TotalDiscs > 0 { - discStr = fmt.Sprintf("%d/%d", metadata.DiscNumber, metadata.TotalDiscs) - } - args = append(args, "-metadata", "disk="+discStr) - } - if metadata.Copyright != "" { - args = append(args, "-metadata", "copyright="+metadata.Copyright) - } - if metadata.Publisher != "" { - args = append(args, "-metadata", "publisher="+metadata.Publisher) - } - composerText := joinMultiValueText(SplitArtistCredits(metadata.Composer, separator), separator, false) - if composerText == "" { - composerText = strings.TrimSpace(metadata.Composer) - } - if composerText != "" { - args = append(args, "-metadata", "composer="+composerText) - } - if metadata.ISRC != "" { - args = append(args, "-metadata", "isrc="+metadata.ISRC) - } - if metadata.UPC != "" { - args = append(args, "-metadata", "upc="+metadata.UPC) - } - genreText := joinMultiValueText(SplitMetadataValues(metadata.Genre, separator), separator, false) - if genreText == "" { - genreText = strings.TrimSpace(metadata.Genre) - } - if genreText != "" { - args = append(args, "-metadata", "genre="+genreText) - } - if comment := resolveMetadataComment(metadata); comment != "" { - args = append(args, "-metadata", "comment="+comment) - } - - tmpOutputFile := strings.TrimSuffix(filePath, pathfilepath.Ext(filePath)) + ".tmp" + pathfilepath.Ext(filePath) - defer func() { - if _, err := os.Stat(tmpOutputFile); err == nil { - os.Remove(tmpOutputFile) - } - }() - - args = append(args, "-f", "ipod", tmpOutputFile) - - cmd := exec.Command(ffmpegPath, args...) - setHideWindow(cmd) - - output, err := cmd.CombinedOutput() - if err != nil { - return fmt.Errorf("ffmpeg failed to embed metadata: %s - %w", string(output), err) - } - - if err := os.Rename(tmpOutputFile, filePath); err != nil { - return fmt.Errorf("failed to replace original file: %w", err) - } - - return nil -} diff --git a/backend/musicbrainz.go b/backend/musicbrainz.go deleted file mode 100644 index f7fdf21..0000000 --- a/backend/musicbrainz.go +++ /dev/null @@ -1,331 +0,0 @@ -package backend - -import ( - "encoding/json" - "fmt" - "net/http" - "net/url" - "strings" - "sync" - "time" - - "golang.org/x/text/cases" - "golang.org/x/text/language" -) - -var AppVersion = "Unknown" - -const ( - musicBrainzAPIBase = "https://musicbrainz.org/ws/2" - musicBrainzRequestTimeout = 10 * time.Second - musicBrainzRequestRetries = 3 - musicBrainzRequestRetryWait = 3 * time.Second - musicBrainzMinRequestInterval = 1100 * time.Millisecond - musicBrainzThrottleCooldownOn503 = 5 * time.Second - musicBrainzStatusCheckSkipWindow = 5 * time.Minute -) - -type musicBrainzStatusError struct { - StatusCode int -} - -func (e *musicBrainzStatusError) Error() string { - return fmt.Sprintf("MusicBrainz API returned status: %d", e.StatusCode) -} - -type musicBrainzInflightCall struct { - done chan struct{} - result Metadata - err error -} - -var ( - musicBrainzCache sync.Map - musicBrainzInflightMu sync.Mutex - musicBrainzInflight = make(map[string]*musicBrainzInflightCall) - - musicBrainzThrottleMu sync.Mutex - musicBrainzNextRequest time.Time - musicBrainzBlockedTill time.Time - - musicBrainzStatusMu sync.RWMutex - musicBrainzLastCheckedAt time.Time - musicBrainzLastCheckedOnline bool -) - -func SetMusicBrainzStatusCheckResult(online bool) { - musicBrainzStatusMu.Lock() - defer musicBrainzStatusMu.Unlock() - - musicBrainzLastCheckedAt = time.Now() - musicBrainzLastCheckedOnline = online -} - -func ShouldSkipMusicBrainzMetadataFetch() bool { - musicBrainzStatusMu.RLock() - defer musicBrainzStatusMu.RUnlock() - - if musicBrainzLastCheckedAt.IsZero() { - return false - } - - if musicBrainzLastCheckedOnline { - return false - } - - return time.Since(musicBrainzLastCheckedAt) <= musicBrainzStatusCheckSkipWindow -} - -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 musicBrainzCacheKey(isrc string, useSingleGenre bool) string { - separator := strings.TrimSpace(GetSeparator()) - if separator == "" { - separator = ";" - } - - return strings.ToUpper(strings.TrimSpace(isrc)) + "|" + fmt.Sprintf("%t", useSingleGenre) + "|" + separator -} - -func waitForMusicBrainzRequestSlot() { - musicBrainzThrottleMu.Lock() - - readyAt := musicBrainzNextRequest - if musicBrainzBlockedTill.After(readyAt) { - readyAt = musicBrainzBlockedTill - } - - now := time.Now() - if readyAt.Before(now) { - readyAt = now - } - - musicBrainzNextRequest = readyAt.Add(musicBrainzMinRequestInterval) - waitDuration := time.Until(readyAt) - - musicBrainzThrottleMu.Unlock() - - if waitDuration > 0 { - time.Sleep(waitDuration) - } -} - -func noteMusicBrainzThrottle() { - musicBrainzThrottleMu.Lock() - defer musicBrainzThrottleMu.Unlock() - - cooldownUntil := time.Now().Add(musicBrainzThrottleCooldownOn503) - if cooldownUntil.After(musicBrainzBlockedTill) { - musicBrainzBlockedTill = cooldownUntil - } - if musicBrainzNextRequest.Before(musicBrainzBlockedTill) { - musicBrainzNextRequest = musicBrainzBlockedTill - } -} - -func shouldRetryMusicBrainzRequest(err error) bool { - if err == nil { - return false - } - - statusErr, ok := err.(*musicBrainzStatusError) - if !ok { - return true - } - - return statusErr.StatusCode == http.StatusServiceUnavailable || statusErr.StatusCode >= http.StatusInternalServerError -} - -func queryMusicBrainzRecordings(client *http.Client, query string) (*MusicBrainzRecordingResponse, error) { - 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(http.MethodGet, reqURL, nil) - if err != nil { - return nil, err - } - - req.Header.Set("User-Agent", fmt.Sprintf("SpotiFLAC/%s ( support@spotbye.qzz.io )", AppVersion)) - req.Header.Set("Accept", "application/json") - - var lastErr error - for attempt := 0; attempt < musicBrainzRequestRetries; attempt++ { - waitForMusicBrainzRequestSlot() - - resp, err := client.Do(req) - if err == nil && resp != nil && resp.StatusCode == http.StatusOK { - defer resp.Body.Close() - - var mbResp MusicBrainzRecordingResponse - if decodeErr := json.NewDecoder(resp.Body).Decode(&mbResp); decodeErr != nil { - return nil, decodeErr - } - - return &mbResp, nil - } - - if err != nil { - lastErr = err - } else if resp == nil { - lastErr = fmt.Errorf("empty response from MusicBrainz") - } else { - if resp.StatusCode == http.StatusServiceUnavailable { - noteMusicBrainzThrottle() - } - lastErr = &musicBrainzStatusError{StatusCode: resp.StatusCode} - resp.Body.Close() - } - - if attempt < musicBrainzRequestRetries-1 && shouldRetryMusicBrainzRequest(lastErr) { - time.Sleep(musicBrainzRequestRetryWait) - continue - } - - break - } - - if lastErr == nil { - lastErr = fmt.Errorf("empty response from MusicBrainz") - } - - return nil, lastErr -} - -func FetchMusicBrainzMetadata(isrc, title, artist, album string, useSingleGenre bool, embedGenre bool) (Metadata, error) { - var meta Metadata - var resultErr error - - if !embedGenre { - return meta, nil - } - - if isrc == "" { - resultErr = fmt.Errorf("no ISRC provided") - return meta, resultErr - } - - cacheKey := musicBrainzCacheKey(isrc, useSingleGenre) - if cached, ok := musicBrainzCache.Load(cacheKey); ok { - return cached.(Metadata), nil - } - - if ShouldSkipMusicBrainzMetadataFetch() { - resultErr = fmt.Errorf("skipping MusicBrainz lookup because the latest status check reported offline") - return meta, resultErr - } - - musicBrainzInflightMu.Lock() - if call, ok := musicBrainzInflight[cacheKey]; ok { - musicBrainzInflightMu.Unlock() - <-call.done - return call.result, call.err - } - - call := &musicBrainzInflightCall{done: make(chan struct{})} - musicBrainzInflight[cacheKey] = call - musicBrainzInflightMu.Unlock() - - defer func() { - call.result = meta - call.err = resultErr - - musicBrainzInflightMu.Lock() - delete(musicBrainzInflight, cacheKey) - close(call.done) - musicBrainzInflightMu.Unlock() - }() - - client := &http.Client{ - Timeout: musicBrainzRequestTimeout, - } - - query := fmt.Sprintf("isrc:%s", isrc) - mbResp, err := queryMusicBrainzRecordings(client, query) - if err != nil { - resultErr = err - return meta, resultErr - } - - if len(mbResp.Recordings) == 0 { - resultErr = fmt.Errorf("no recordings found for ISRC: %s", isrc) - return meta, resultErr - } - - 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()) - } - } - - if meta.Genre == "" { - resultErr = fmt.Errorf("no genre tags found in MusicBrainz") - return meta, resultErr - } - - musicBrainzCache.Store(cacheKey, meta) - - return meta, nil -} diff --git a/backend/progress.go b/backend/progress.go deleted file mode 100644 index 0a43155..0000000 --- a/backend/progress.go +++ /dev/null @@ -1,419 +0,0 @@ -package backend - -import ( - "fmt" - "io" - "sync" - "time" -) - -type DownloadStatus string - -const ( - StatusQueued DownloadStatus = "queued" - StatusDownloading DownloadStatus = "downloading" - StatusCompleted DownloadStatus = "completed" - StatusFailed DownloadStatus = "failed" - StatusSkipped DownloadStatus = "skipped" -) - -type DownloadItem struct { - ID string `json:"id"` - TrackName string `json:"track_name"` - ArtistName string `json:"artist_name"` - AlbumName string `json:"album_name"` - SpotifyID string `json:"spotify_id"` - Status DownloadStatus `json:"status"` - Progress float64 `json:"progress"` - TotalSize float64 `json:"total_size"` - Speed float64 `json:"speed"` - StartTime int64 `json:"start_time"` - EndTime int64 `json:"end_time"` - ErrorMessage string `json:"error_message"` - FilePath string `json:"file_path"` -} - -var ( - currentProgress float64 - currentProgressLock sync.RWMutex - isDownloading bool - downloadingLock sync.RWMutex - currentSpeed float64 - speedLock sync.RWMutex - - downloadQueue []DownloadItem - downloadQueueLock sync.RWMutex - currentItemID string - currentItemLock sync.RWMutex - totalDownloaded float64 - totalDownloadedLock sync.RWMutex - sessionStartTime int64 - sessionStartLock sync.RWMutex -) - -type ProgressInfo struct { - IsDownloading bool `json:"is_downloading"` - MBDownloaded float64 `json:"mb_downloaded"` - SpeedMBps float64 `json:"speed_mbps"` -} - -type DownloadQueueInfo struct { - IsDownloading bool `json:"is_downloading"` - Queue []DownloadItem `json:"queue"` - CurrentSpeed float64 `json:"current_speed"` - TotalDownloaded float64 `json:"total_downloaded"` - SessionStartTime int64 `json:"session_start_time"` - QueuedCount int `json:"queued_count"` - CompletedCount int `json:"completed_count"` - FailedCount int `json:"failed_count"` - SkippedCount int `json:"skipped_count"` -} - -func GetDownloadProgress() ProgressInfo { - downloadingLock.RLock() - downloading := isDownloading - downloadingLock.RUnlock() - - currentProgressLock.RLock() - progress := currentProgress - currentProgressLock.RUnlock() - - speedLock.RLock() - speed := currentSpeed - speedLock.RUnlock() - - return ProgressInfo{ - IsDownloading: downloading, - MBDownloaded: progress, - SpeedMBps: speed, - } -} - -func SetDownloadSpeed(mbps float64) { - speedLock.Lock() - currentSpeed = mbps - speedLock.Unlock() -} - -func SetDownloadProgress(mbDownloaded float64) { - currentProgressLock.Lock() - currentProgress = mbDownloaded - currentProgressLock.Unlock() -} - -func SetDownloading(downloading bool) { - downloadingLock.Lock() - isDownloading = downloading - downloadingLock.Unlock() - - if !downloading { - - SetDownloadProgress(0) - SetDownloadSpeed(0) - } -} - -type ProgressWriter struct { - writer io.Writer - total int64 - lastPrinted int64 - startTime int64 - lastTime int64 - lastBytes int64 - itemID string -} - -func NewProgressWriter(writer io.Writer) *ProgressWriter { - now := getCurrentTimeMillis() - return &ProgressWriter{ - writer: writer, - total: 0, - lastPrinted: 0, - startTime: now, - lastTime: now, - lastBytes: 0, - itemID: "", - } -} - -func NewProgressWriterWithID(writer io.Writer, itemID string) *ProgressWriter { - pw := NewProgressWriter(writer) - pw.itemID = itemID - return pw -} - -func getCurrentTimeMillis() int64 { - return time.Now().UnixMilli() -} - -func (pw *ProgressWriter) Write(p []byte) (int, error) { - n, err := pw.writer.Write(p) - pw.total += int64(n) - - if pw.total-pw.lastPrinted >= 256*1024 { - mbDownloaded := float64(pw.total) / (1024 * 1024) - - now := getCurrentTimeMillis() - timeDiff := float64(now-pw.lastTime) / 1000.0 - bytesDiff := float64(pw.total - pw.lastBytes) - - var speedMBps float64 - if timeDiff > 0 { - speedMBps = (bytesDiff / (1024 * 1024)) / timeDiff - SetDownloadSpeed(speedMBps) - fmt.Printf("\rDownloaded: %.2f MB (%.2f MB/s)", mbDownloaded, speedMBps) - } else { - fmt.Printf("\rDownloaded: %.2f MB", mbDownloaded) - } - - SetDownloadProgress(mbDownloaded) - - if pw.itemID != "" { - UpdateItemProgress(pw.itemID, mbDownloaded, speedMBps) - } - - pw.lastPrinted = pw.total - pw.lastTime = now - pw.lastBytes = pw.total - } - - return n, err -} - -func (pw *ProgressWriter) GetTotal() int64 { - return pw.total -} - -func AddToQueue(id, trackName, artistName, albumName, spotifyID string) { - downloadQueueLock.Lock() - defer downloadQueueLock.Unlock() - - item := DownloadItem{ - ID: id, - TrackName: trackName, - ArtistName: artistName, - AlbumName: albumName, - SpotifyID: spotifyID, - Status: StatusQueued, - Progress: 0, - TotalSize: 0, - Speed: 0, - StartTime: 0, - EndTime: 0, - } - - downloadQueue = append(downloadQueue, item) - - sessionStartLock.Lock() - if sessionStartTime == 0 { - sessionStartTime = time.Now().Unix() - } - sessionStartLock.Unlock() -} - -func StartDownloadItem(id string) { - downloadQueueLock.Lock() - defer downloadQueueLock.Unlock() - - for i := range downloadQueue { - if downloadQueue[i].ID == id { - downloadQueue[i].Status = StatusDownloading - downloadQueue[i].StartTime = time.Now().Unix() - downloadQueue[i].Progress = 0 - break - } - } - - currentItemLock.Lock() - currentItemID = id - currentItemLock.Unlock() -} - -func UpdateItemProgress(id string, progress, speed float64) { - downloadQueueLock.Lock() - defer downloadQueueLock.Unlock() - - for i := range downloadQueue { - if downloadQueue[i].ID == id { - downloadQueue[i].Progress = progress - downloadQueue[i].Speed = speed - break - } - } -} - -func GetCurrentItemID() string { - currentItemLock.RLock() - defer currentItemLock.RUnlock() - return currentItemID -} - -func CompleteDownloadItem(id, filePath string, finalSize float64) { - downloadQueueLock.Lock() - defer downloadQueueLock.Unlock() - - for i := range downloadQueue { - if downloadQueue[i].ID == id { - downloadQueue[i].Status = StatusCompleted - downloadQueue[i].EndTime = time.Now().Unix() - downloadQueue[i].FilePath = filePath - downloadQueue[i].Progress = finalSize - downloadQueue[i].TotalSize = finalSize - - totalDownloadedLock.Lock() - totalDownloaded += finalSize - totalDownloadedLock.Unlock() - break - } - } -} - -func FailDownloadItem(id, errorMsg string) { - downloadQueueLock.Lock() - defer downloadQueueLock.Unlock() - - for i := range downloadQueue { - if downloadQueue[i].ID == id { - downloadQueue[i].Status = StatusFailed - downloadQueue[i].EndTime = time.Now().Unix() - downloadQueue[i].ErrorMessage = errorMsg - break - } - } -} - -func SkipDownloadItem(id, filePath string) { - downloadQueueLock.Lock() - defer downloadQueueLock.Unlock() - - for i := range downloadQueue { - if downloadQueue[i].ID == id { - downloadQueue[i].Status = StatusSkipped - downloadQueue[i].EndTime = time.Now().Unix() - downloadQueue[i].FilePath = filePath - break - } - } -} - -func GetDownloadQueue() DownloadQueueInfo { - - ResetSessionIfComplete() - - downloadQueueLock.RLock() - defer downloadQueueLock.RUnlock() - - downloadingLock.RLock() - downloading := isDownloading - downloadingLock.RUnlock() - - speedLock.RLock() - speed := currentSpeed - speedLock.RUnlock() - - totalDownloadedLock.RLock() - total := totalDownloaded - totalDownloadedLock.RUnlock() - - sessionStartLock.RLock() - sessionStart := sessionStartTime - sessionStartLock.RUnlock() - - var queued, completed, failed, skipped int - for _, item := range downloadQueue { - switch item.Status { - case StatusQueued: - queued++ - case StatusCompleted: - completed++ - case StatusFailed: - failed++ - case StatusSkipped: - skipped++ - } - } - - queueCopy := make([]DownloadItem, len(downloadQueue)) - copy(queueCopy, downloadQueue) - - return DownloadQueueInfo{ - IsDownloading: downloading, - Queue: queueCopy, - CurrentSpeed: speed, - TotalDownloaded: total, - SessionStartTime: sessionStart, - QueuedCount: queued, - CompletedCount: completed, - FailedCount: failed, - SkippedCount: skipped, - } -} - -func ClearDownloadQueue() { - downloadQueueLock.Lock() - defer downloadQueueLock.Unlock() - - newQueue := make([]DownloadItem, 0) - for _, item := range downloadQueue { - if item.Status == StatusQueued || item.Status == StatusDownloading { - newQueue = append(newQueue, item) - } - } - downloadQueue = newQueue -} - -func ClearAllDownloads() { - downloadQueueLock.Lock() - downloadQueue = []DownloadItem{} - downloadQueueLock.Unlock() - - totalDownloadedLock.Lock() - totalDownloaded = 0 - totalDownloadedLock.Unlock() - - sessionStartLock.Lock() - sessionStartTime = 0 - sessionStartLock.Unlock() - - currentItemLock.Lock() - currentItemID = "" - currentItemLock.Unlock() - - SetDownloadProgress(0) - SetDownloadSpeed(0) -} - -func CancelAllQueuedItems() { - downloadQueueLock.Lock() - defer downloadQueueLock.Unlock() - - for i := range downloadQueue { - if downloadQueue[i].Status == StatusQueued { - downloadQueue[i].Status = StatusSkipped - downloadQueue[i].EndTime = time.Now().Unix() - downloadQueue[i].ErrorMessage = "Cancelled" - } - } -} - -func ResetSessionIfComplete() { - downloadQueueLock.RLock() - hasActiveOrQueued := false - for _, item := range downloadQueue { - if item.Status == StatusQueued || item.Status == StatusDownloading { - hasActiveOrQueued = true - break - } - } - downloadQueueLock.RUnlock() - - if !hasActiveOrQueued { - sessionStartLock.Lock() - sessionStartTime = 0 - sessionStartLock.Unlock() - - totalDownloadedLock.Lock() - totalDownloaded = 0 - totalDownloadedLock.Unlock() - } -} diff --git a/backend/provider_endpoints.go b/backend/provider_endpoints.go deleted file mode 100644 index 9c9ae9b..0000000 --- a/backend/provider_endpoints.go +++ /dev/null @@ -1,88 +0,0 @@ -package backend - -import ( - "net/url" - "strings" -) - -const amazonMusicAPIBaseURL = "https://amazon.spotbye.qzz.io" - -const ( - qobuzWJHEBaseURL = "https://music.wjhe.top" - qobuzWJHESearchAPIURL = qobuzWJHEBaseURL + "/api/music/qobuz/search" - qobuzWJHEStreamAPIURL = qobuzWJHEBaseURL + "/api/music/qobuz/url" - qobuzMusicDLDownloadAPIURL = "https://www.musicdl.me/api/qobuz/download" - qobuzGDStudioAPIURLXYZ = "https://music.gdstudio.xyz/api.php" - qobuzGDStudioAPIURLORG = "https://music.gdstudio.org/api.php" - qobuzGDStudioVersion = "2026.5.10" -) - -var defaultQobuzDownloadProviderURLs = []string{ - qobuzWJHEStreamAPIURL, - qobuzGDStudioAPIURLXYZ, - qobuzGDStudioAPIURLORG, - qobuzMusicDLDownloadAPIURL, -} - -func GetQobuzDownloadProviderURLs() []string { - return append([]string(nil), defaultQobuzDownloadProviderURLs...) -} - -func GetQobuzWJHESearchAPIURL() string { - return qobuzWJHESearchAPIURL -} - -func GetQobuzWJHEStreamAPIURL() string { - return qobuzWJHEStreamAPIURL -} - -func GetQobuzMusicDLDownloadAPIURL() string { - return qobuzMusicDLDownloadAPIURL -} - -func GetQobuzGDStudioAPIURLs() []string { - return []string{qobuzGDStudioAPIURLXYZ, qobuzGDStudioAPIURLORG} -} - -func GetQobuzGDStudioPrimaryAPIURL() string { - return qobuzGDStudioAPIURLXYZ -} - -func GetQobuzGDStudioFallbackAPIURL() string { - return qobuzGDStudioAPIURLORG -} - -func GetQobuzGDStudioSignatureHost(apiURL string) string { - parsed, err := url.Parse(strings.TrimSpace(apiURL)) - if err != nil || strings.TrimSpace(parsed.Host) == "" { - return "" - } - return strings.TrimSpace(parsed.Host) -} - -func GetQobuzGDStudioVersion() string { - return qobuzGDStudioVersion -} - -func IsQobuzWJHEProviderURL(raw string) bool { - candidate := strings.TrimSpace(raw) - return candidate == qobuzWJHEStreamAPIURL || strings.HasPrefix(candidate, qobuzWJHEStreamAPIURL+"?") -} - -func IsQobuzMusicDLProviderURL(raw string) bool { - return strings.EqualFold(strings.TrimSpace(raw), qobuzMusicDLDownloadAPIURL) -} - -func IsQobuzGDStudioProviderURL(raw string) bool { - candidate := strings.TrimSpace(raw) - for _, apiURL := range GetQobuzGDStudioAPIURLs() { - if candidate == apiURL || strings.HasPrefix(candidate, apiURL+"?") { - return true - } - } - return false -} - -func GetAmazonMusicAPIBaseURL() string { - return amazonMusicAPIBaseURL -} diff --git a/backend/provider_priority.go b/backend/provider_priority.go deleted file mode 100644 index 68377ef..0000000 --- a/backend/provider_priority.go +++ /dev/null @@ -1,215 +0,0 @@ -package backend - -import ( - "encoding/json" - "fmt" - "path/filepath" - "sort" - "strings" - "sync" - "time" - - bolt "go.etcd.io/bbolt" -) - -const ( - providerPriorityDBFile = "provider_priority.db" - providerPriorityBucket = "ProviderPriority" -) - -type providerPriorityEntry struct { - Service string `json:"service"` - Provider string `json:"provider"` - LastOutcome string `json:"last_outcome"` - LastAttempt int64 `json:"last_attempt"` - LastSuccess int64 `json:"last_success"` - LastFailure int64 `json:"last_failure"` - SuccessCount int64 `json:"success_count"` - FailureCount int64 `json:"failure_count"` -} - -var ( - providerPriorityDB *bolt.DB - providerPriorityDBMu sync.Mutex -) - -func InitProviderPriorityDB() error { - providerPriorityDBMu.Lock() - defer providerPriorityDBMu.Unlock() - - if providerPriorityDB != nil { - return nil - } - - appDir, err := EnsureAppDir() - if err != nil { - return err - } - - dbPath := filepath.Join(appDir, providerPriorityDBFile) - db, err := bolt.Open(dbPath, 0o600, &bolt.Options{Timeout: 1 * time.Second}) - if err != nil { - return err - } - - if err := db.Update(func(tx *bolt.Tx) error { - _, err := tx.CreateBucketIfNotExists([]byte(providerPriorityBucket)) - return err - }); err != nil { - db.Close() - return err - } - - providerPriorityDB = db - return nil -} - -func CloseProviderPriorityDB() { - providerPriorityDBMu.Lock() - defer providerPriorityDBMu.Unlock() - - if providerPriorityDB != nil { - _ = providerPriorityDB.Close() - providerPriorityDB = nil - } -} - -func prioritizeProviders(service string, providers []string) []string { - ordered := append([]string(nil), providers...) - if len(ordered) < 2 { - return ordered - } - - if err := InitProviderPriorityDB(); err != nil { - fmt.Printf("Warning: failed to init provider priority DB: %v\n", err) - return ordered - } - - serviceKey := strings.TrimSpace(strings.ToLower(service)) - entries := make(map[string]providerPriorityEntry, len(ordered)) - - if err := providerPriorityDB.View(func(tx *bolt.Tx) error { - bucket := tx.Bucket([]byte(providerPriorityBucket)) - if bucket == nil { - return nil - } - - for _, provider := range ordered { - if raw := bucket.Get([]byte(providerPriorityKey(serviceKey, provider))); len(raw) > 0 { - var entry providerPriorityEntry - if err := json.Unmarshal(raw, &entry); err != nil { - return err - } - entries[provider] = entry - } - } - return nil - }); err != nil { - fmt.Printf("Warning: failed to read provider priority DB: %v\n", err) - return ordered - } - - originalIndex := make(map[string]int, len(ordered)) - for idx, provider := range ordered { - originalIndex[provider] = idx - } - - sort.SliceStable(ordered, func(i, j int) bool { - left := entries[ordered[i]] - right := entries[ordered[j]] - - leftRank := providerOutcomeRank(left.LastOutcome) - rightRank := providerOutcomeRank(right.LastOutcome) - if leftRank != rightRank { - return leftRank > rightRank - } - - if left.LastSuccess != right.LastSuccess { - return left.LastSuccess > right.LastSuccess - } - - if left.LastAttempt != right.LastAttempt { - return left.LastAttempt > right.LastAttempt - } - - return originalIndex[ordered[i]] < originalIndex[ordered[j]] - }) - - return ordered -} - -func recordProviderSuccess(service string, provider string) { - recordProviderOutcome(service, provider, true) -} - -func recordProviderFailure(service string, provider string) { - recordProviderOutcome(service, provider, false) -} - -func recordProviderOutcome(service string, provider string, success bool) { - if strings.TrimSpace(service) == "" || strings.TrimSpace(provider) == "" { - return - } - - if err := InitProviderPriorityDB(); err != nil { - fmt.Printf("Warning: failed to init provider priority DB: %v\n", err) - return - } - - serviceKey := strings.TrimSpace(strings.ToLower(service)) - providerKey := providerPriorityKey(serviceKey, provider) - now := time.Now().Unix() - - if err := providerPriorityDB.Update(func(tx *bolt.Tx) error { - bucket, err := tx.CreateBucketIfNotExists([]byte(providerPriorityBucket)) - if err != nil { - return err - } - - entry := providerPriorityEntry{ - Service: serviceKey, - Provider: provider, - } - - if raw := bucket.Get([]byte(providerKey)); len(raw) > 0 { - if err := json.Unmarshal(raw, &entry); err != nil { - return err - } - } - - entry.LastAttempt = now - if success { - entry.LastOutcome = "success" - entry.LastSuccess = now - entry.SuccessCount++ - } else { - entry.LastOutcome = "failure" - entry.LastFailure = now - entry.FailureCount++ - } - - payload, err := json.Marshal(entry) - if err != nil { - return err - } - - return bucket.Put([]byte(providerKey), payload) - }); err != nil { - fmt.Printf("Warning: failed to update provider priority DB: %v\n", err) - } -} - -func providerOutcomeRank(outcome string) int { - switch strings.TrimSpace(strings.ToLower(outcome)) { - case "success": - return 2 - case "": - return 1 - default: - return 0 - } -} - -func providerPriorityKey(service string, provider string) string { - return strings.TrimSpace(strings.ToLower(service)) + "|" + strings.TrimSpace(provider) -} diff --git a/backend/qobuz.go b/backend/qobuz.go deleted file mode 100644 index 0a6dea5..0000000 --- a/backend/qobuz.go +++ /dev/null @@ -1,1127 +0,0 @@ -package backend - -import ( - "bytes" - "crypto/aes" - "crypto/cipher" - "crypto/md5" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "os" - "path/filepath" - "regexp" - "strconv" - "strings" - "sync" - "time" -) - -type QobuzDownloader struct { - client *http.Client -} - -type QobuzTrack struct { - ID int64 `json:"id"` - Title string `json:"title"` - Version string `json:"version"` - Duration int `json:"duration"` - TrackNumber int `json:"track_number"` - MediaNumber int `json:"media_number"` - ISRC string `json:"isrc"` - Copyright string `json:"copyright"` - MaximumBitDepth int `json:"maximum_bit_depth"` - MaximumSamplingRate float64 `json:"maximum_sampling_rate"` - Hires bool `json:"hires"` - HiresStreamable bool `json:"hires_streamable"` - ReleaseDateOriginal string `json:"release_date_original"` - Performer struct { - Name string `json:"name"` - ID int64 `json:"id"` - } `json:"performer"` - Album struct { - Title string `json:"title"` - ID string `json:"id"` - Image struct { - Small string `json:"small"` - Thumbnail string `json:"thumbnail"` - Large string `json:"large"` - } `json:"image"` - Artist struct { - Name string `json:"name"` - ID int64 `json:"id"` - } `json:"artist"` - Label struct { - Name string `json:"name"` - } `json:"label"` - } `json:"album"` -} - -type qobuzMusicDLRequest struct { - URL string `json:"url"` - Quality string `json:"quality"` -} - -type qobuzMusicDLResponse struct { - Success bool `json:"success"` - Type string `json:"type"` - URLType string `json:"url_type"` - TrackID string `json:"track_id"` - Quality string `json:"quality_label"` - DownloadURL string `json:"download_url"` - Message string `json:"message"` - Error string `json:"error"` -} - -type qobuzPublicSearchResponse struct { - Tracks struct { - Total int `json:"total"` - Items []QobuzTrack `json:"items"` - } `json:"tracks"` -} - -const qobuzProbeTrackID int64 = 341032040 - -var ( - qobuzMusicDLDebugKeyOnce sync.Once - qobuzMusicDLDebugKey string - qobuzMusicDLDebugKeyErr error - qobuzStreamingURLPattern = regexp.MustCompile(`https?://[^\s"'<>\\)]+`) -) - -var qobuzMusicDLDebugKeySeedParts = [][]byte{ - {0x73, 0x70, 0x6f, 0x74, 0x69, 0x66}, - {0x6c, 0x61, 0x63, 0x3a, 0x71, 0x6f}, - {0x62, 0x75, 0x7a, 0x3a, 0x6d, 0x75, 0x73, 0x69, 0x63, 0x64, 0x6c, 0x3a, 0x76, 0x31}, -} - -var qobuzMusicDLDebugKeyAAD = []byte{ - 0x71, 0x6f, 0x62, 0x75, 0x7a, 0x7c, 0x6d, 0x75, 0x73, 0x69, 0x63, 0x64, - 0x6c, 0x7c, 0x64, 0x65, 0x62, 0x75, 0x67, 0x7c, 0x76, 0x31, -} - -var qobuzMusicDLDebugKeyNonce = []byte{ - 0x91, 0x2a, 0x5c, 0x77, 0x0f, 0x33, 0xa8, 0x14, 0x62, 0x9d, 0xce, 0x41, -} - -var qobuzMusicDLDebugKeyCiphertext = []byte{ - 0xf3, 0x4a, 0x83, 0x45, 0x24, 0xb6, 0x22, 0xaf, 0xd6, 0xc3, 0x6e, 0x2d, - 0x56, 0xd1, 0xbb, 0x0b, 0xe9, 0x1b, 0x4f, 0x1c, 0x5f, 0x41, 0x55, 0xc2, - 0xc6, 0xdf, 0xad, 0x21, 0x58, 0xfe, 0xd5, 0xb8, 0x2d, 0x29, 0xf9, 0x9e, - 0x6f, 0xd6, -} - -var qobuzMusicDLDebugKeyTag = []byte{ - 0x69, 0x0c, 0x42, 0x70, 0x14, 0x83, 0xff, 0x14, 0xc8, 0xbe, 0x17, 0x00, - 0x69, 0xb1, 0xfe, 0xbb, -} - -func NewQobuzDownloader() *QobuzDownloader { - return &QobuzDownloader{ - client: &http.Client{ - Timeout: 60 * time.Second, - }, - } -} - -func previewQobuzResponseBody(body []byte, maxLen int) string { - preview := strings.TrimSpace(string(body)) - if len(preview) > maxLen { - return preview[:maxLen] + "..." - } - return preview -} - -func buildQobuzOpenTrackURL(trackID int64) string { - return fmt.Sprintf("https://open.qobuz.com/track/%d", trackID) -} - -func getQobuzMusicDLDebugKey() (string, error) { - qobuzMusicDLDebugKeyOnce.Do(func() { - hasher := sha256.New() - for _, part := range qobuzMusicDLDebugKeySeedParts { - hasher.Write(part) - } - - block, err := aes.NewCipher(hasher.Sum(nil)) - if err != nil { - qobuzMusicDLDebugKeyErr = err - return - } - - gcm, err := cipher.NewGCM(block) - if err != nil { - qobuzMusicDLDebugKeyErr = err - return - } - - sealed := make([]byte, 0, len(qobuzMusicDLDebugKeyCiphertext)+len(qobuzMusicDLDebugKeyTag)) - sealed = append(sealed, qobuzMusicDLDebugKeyCiphertext...) - sealed = append(sealed, qobuzMusicDLDebugKeyTag...) - - plaintext, err := gcm.Open(nil, qobuzMusicDLDebugKeyNonce, sealed, qobuzMusicDLDebugKeyAAD) - if err != nil { - qobuzMusicDLDebugKeyErr = err - return - } - - qobuzMusicDLDebugKey = string(plaintext) - }) - - if qobuzMusicDLDebugKeyErr != nil { - return "", qobuzMusicDLDebugKeyErr - } - - return qobuzMusicDLDebugKey, nil -} - -func firstNonEmptyQobuzValue(values ...string) string { - for _, value := range values { - trimmed := strings.TrimSpace(value) - if trimmed != "" { - return trimmed - } - } - return "" -} - -func normalizeQobuzSearchValue(value string) string { - replacer := strings.NewReplacer( - "&", " and ", - "feat.", " ", - "ft.", " ", - "/", " ", - "-", " ", - "_", " ", - ) - normalized := strings.ToLower(strings.TrimSpace(value)) - normalized = replacer.Replace(normalized) - return strings.Join(strings.Fields(normalized), " ") -} - -func qobuzTrackDisplayArtist(track QobuzTrack) string { - return firstNonEmptyQobuzValue(track.Performer.Name, track.Album.Artist.Name) -} - -func qobuzTrackSupportsHiRes(track QobuzTrack) bool { - if track.Hires || track.HiresStreamable { - return true - } - return track.MaximumBitDepth >= 24 || track.MaximumSamplingRate > 48 -} - -func scoreQobuzSearchCandidate(track QobuzTrack, spotifyTrackName string, spotifyArtistName string, spotifyAlbumName string) int { - score := 0 - - titleNeedle := normalizeQobuzSearchValue(spotifyTrackName) - titleHaystack := normalizeQobuzSearchValue(track.Title) - switch { - case titleNeedle != "" && titleHaystack == titleNeedle: - score += 1000 - case titleNeedle != "" && (strings.Contains(titleHaystack, titleNeedle) || strings.Contains(titleNeedle, titleHaystack)): - score += 500 - } - - artistNeedle := normalizeQobuzSearchValue(spotifyArtistName) - artistHaystack := normalizeQobuzSearchValue(qobuzTrackDisplayArtist(track)) - switch { - case artistNeedle != "" && artistHaystack == artistNeedle: - score += 300 - case artistNeedle != "" && artistHaystack != "" && (strings.Contains(artistHaystack, artistNeedle) || strings.Contains(artistNeedle, artistHaystack)): - score += 180 - } - - albumNeedle := normalizeQobuzSearchValue(spotifyAlbumName) - albumHaystack := normalizeQobuzSearchValue(track.Album.Title) - switch { - case albumNeedle != "" && albumHaystack == albumNeedle: - score += 150 - case albumNeedle != "" && albumHaystack != "" && (strings.Contains(albumHaystack, albumNeedle) || strings.Contains(albumNeedle, albumHaystack)): - score += 90 - } - - if qobuzTrackSupportsHiRes(track) { - score += 40 - } else if track.MaximumBitDepth >= 16 { - score += 20 - } - - return score -} - -func mapQobuzWJHEQuality(quality string) (int, string) { - switch strings.TrimSpace(quality) { - case "27", "7": - return 2000, "flac" - case "", "6": - return 1000, "flac" - default: - return 320, "mp3" - } -} - -func buildQobuzWJHEDownloadURL(trackID int64, quality string) string { - wjheQuality, wjheFormat := mapQobuzWJHEQuality(quality) - params := url.Values{ - "ID": {strconv.FormatInt(trackID, 10)}, - "quality": {strconv.Itoa(wjheQuality)}, - "format": {wjheFormat}, - } - return GetQobuzWJHEStreamAPIURL() + "?" + params.Encode() -} - -func qobuzURLLooksStreamable(raw string) bool { - candidate := strings.TrimSpace(raw) - if candidate == "" { - return false - } - - parsed, err := url.Parse(candidate) - if err != nil { - return false - } - - return (parsed.Scheme == "http" || parsed.Scheme == "https") && parsed.Host != "" -} - -func findQobuzStreamingURLInPayload(payload interface{}) string { - switch value := payload.(type) { - case string: - candidate := strings.ReplaceAll(strings.TrimSpace(value), `\/`, `/`) - if qobuzURLLooksStreamable(candidate) { - return candidate - } - case []interface{}: - for _, item := range value { - if url := findQobuzStreamingURLInPayload(item); url != "" { - return url - } - } - case map[string]interface{}: - for _, key := range []string{"download_url", "url", "play_url", "stream_url", "link", "file"} { - if nested, ok := value[key]; ok { - if url := findQobuzStreamingURLInPayload(nested); url != "" { - return url - } - } - } - for _, nested := range value { - if url := findQobuzStreamingURLInPayload(nested); url != "" { - return url - } - } - } - - return "" -} - -func extractQobuzStreamingURL(body []byte) string { - trimmed := strings.TrimSpace(string(body)) - if trimmed == "" { - return "" - } - - var directResp struct { - URL string `json:"url"` - DownloadURL string `json:"download_url"` - Data struct { - URL string `json:"url"` - DownloadURL string `json:"download_url"` - } `json:"data"` - } - if err := json.Unmarshal(body, &directResp); err == nil { - for _, candidate := range []string{ - directResp.DownloadURL, - directResp.URL, - directResp.Data.DownloadURL, - directResp.Data.URL, - } { - if qobuzURLLooksStreamable(candidate) { - return candidate - } - } - } - - var genericPayload interface{} - if err := json.Unmarshal(body, &genericPayload); err == nil { - if streamURL := findQobuzStreamingURLInPayload(genericPayload); streamURL != "" { - return streamURL - } - } - - if openIdx := strings.Index(trimmed, "("); openIdx >= 0 { - if closeIdx := strings.LastIndex(trimmed, ")"); closeIdx > openIdx+1 { - callbackBody := strings.TrimSpace(trimmed[openIdx+1 : closeIdx]) - if streamURL := extractQobuzStreamingURL([]byte(callbackBody)); streamURL != "" { - return streamURL - } - } - } - - for _, match := range qobuzStreamingURLPattern.FindAllString(trimmed, -1) { - candidate := strings.ReplaceAll(match, `\/`, `/`) - if qobuzURLLooksStreamable(candidate) { - return candidate - } - } - - return "" -} - -func newQobuzNoRedirectClient(base *http.Client) *http.Client { - if base == nil { - return &http.Client{ - Timeout: 20 * time.Second, - CheckRedirect: func(req *http.Request, via []*http.Request) error { - return http.ErrUseLastResponse - }, - } - } - - cloned := *base - if cloned.Timeout == 0 { - cloned.Timeout = 20 * time.Second - } - cloned.CheckRedirect = func(req *http.Request, via []*http.Request) error { - return http.ErrUseLastResponse - } - return &cloned -} - -func (q *QobuzDownloader) searchByISRC(isrc string, spotifyTrackName string, spotifyArtistName string, spotifyAlbumName string) (*QobuzTrack, error) { - if strings.HasPrefix(isrc, "qobuz_") { - trackID := strings.TrimSpace(strings.TrimPrefix(isrc, "qobuz_")) - resp, err := doQobuzSignedRequest(http.MethodGet, "track/get", url.Values{"track_id": {trackID}}, q.client) - if err != nil { - return nil, fmt.Errorf("failed to fetch track from Qobuz public API: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) - return nil, fmt.Errorf("Qobuz public API track/get returned status %d: %s", resp.StatusCode, previewQobuzResponseBody(body, 256)) - } - - var trackResp QobuzTrack - if err := json.NewDecoder(resp.Body).Decode(&trackResp); err != nil { - return nil, fmt.Errorf("failed to decode Qobuz public track/get response: %w", err) - } - - return &trackResp, nil - } - - queries := []string{strings.TrimSpace(isrc)} - if fallbackQuery := strings.TrimSpace(strings.Join([]string{spotifyTrackName, spotifyArtistName}, " ")); fallbackQuery != "" { - queries = append(queries, fallbackQuery) - } - - var lastErr error - for _, query := range queries { - if strings.TrimSpace(query) == "" { - continue - } - - var searchResp qobuzPublicSearchResponse - if err := doQobuzSignedJSONRequest("track/search", url.Values{ - "query": {strings.TrimSpace(query)}, - "limit": {"10"}, - }, &searchResp); err != nil { - lastErr = fmt.Errorf("failed to search Qobuz public API: %w", err) - continue - } - - if searchResp.Tracks.Total == 0 || len(searchResp.Tracks.Items) == 0 { - lastErr = fmt.Errorf("track not found for query: %s", query) - continue - } - - bestIndex := 0 - bestScore := -1 - for idx, candidate := range searchResp.Tracks.Items { - score := scoreQobuzSearchCandidate(candidate, spotifyTrackName, spotifyArtistName, spotifyAlbumName) - if idx == 0 || score > bestScore { - bestIndex = idx - bestScore = score - } - } - - selected := searchResp.Tracks.Items[bestIndex] - return &selected, nil - } - - if lastErr == nil { - lastErr = fmt.Errorf("track not found for ISRC: %s", isrc) - } - return nil, lastErr -} - -func (q *QobuzDownloader) DownloadFromWJHE(trackID int64, quality string) (string, error) { - apiURL := buildQobuzWJHEDownloadURL(trackID, quality) - client := newQobuzNoRedirectClient(q.client) - - req, err := NewRequestWithDefaultHeaders(http.MethodHead, apiURL, nil) - if err != nil { - return "", fmt.Errorf("failed to create WJHE request: %w", err) - } - - resp, err := client.Do(req) - if err != nil { - return "", fmt.Errorf("failed to reach WJHE: %w", err) - } - - if resp.StatusCode == http.StatusMethodNotAllowed || resp.StatusCode == http.StatusNotImplemented { - resp.Body.Close() - req, err = NewRequestWithDefaultHeaders(http.MethodGet, apiURL, nil) - if err != nil { - return "", fmt.Errorf("failed to create WJHE fallback request: %w", err) - } - resp, err = client.Do(req) - if err != nil { - return "", fmt.Errorf("failed to reach WJHE with GET fallback: %w", err) - } - } - defer resp.Body.Close() - - if location := strings.TrimSpace(resp.Header.Get("Location")); qobuzURLLooksStreamable(location) { - return location, nil - } - - body, err := io.ReadAll(io.LimitReader(resp.Body, 128*1024)) - if err != nil { - return "", fmt.Errorf("failed to read WJHE response: %w", err) - } - - if streamURL := extractQobuzStreamingURL(body); streamURL != "" { - return streamURL, nil - } - - if resp.Request != nil && resp.Request.URL != nil { - if streamURL := strings.TrimSpace(resp.Request.URL.String()); streamURL != "" && streamURL != apiURL && qobuzURLLooksStreamable(streamURL) { - return streamURL, nil - } - } - - if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusBadRequest { - return "", fmt.Errorf("WJHE returned status %d: %s", resp.StatusCode, previewQobuzResponseBody(body, 256)) - } - - return "", fmt.Errorf("WJHE response did not include a stream URL") -} - -func qobuzGDStudioPaddedVersion() string { - parts := strings.Split(GetQobuzGDStudioVersion(), ".") - for idx, part := range parts { - part = strings.TrimSpace(part) - if len(part) == 1 { - part = "0" + part - } - parts[idx] = part - } - return strings.Join(parts, "") -} - -func qobuzGDStudioEscapedValue(value string) string { - return strings.ReplaceAll(url.QueryEscape(strings.TrimSpace(value)), "+", "%20") -} - -func (q *QobuzDownloader) getQobuzGDStudioTS9(apiURL string) string { - fallback := strconv.FormatInt(time.Now().UnixMilli(), 10) - if len(fallback) >= 9 { - fallback = fallback[:9] - } - - client := q.client - if client == nil { - client = &http.Client{Timeout: 10 * time.Second} - } - - signatureHost := GetQobuzGDStudioSignatureHost(apiURL) - if signatureHost == "" { - return fallback - } - - req, err := NewRequestWithDefaultHeaders(http.MethodGet, fmt.Sprintf("https://%s/time", signatureHost), nil) - if err != nil { - return fallback - } - - resp, err := client.Do(req) - if err != nil { - return fallback - } - defer resp.Body.Close() - - body, err := io.ReadAll(io.LimitReader(resp.Body, 64)) - if err != nil { - return fallback - } - - timestamp := strings.TrimSpace(string(body)) - if len(timestamp) >= 9 { - return timestamp[:9] - } - - return fallback -} - -func buildQobuzGDStudioSignature(apiURL string, value string, ts9 string) string { - signatureHost := GetQobuzGDStudioSignatureHost(apiURL) - signatureBase := fmt.Sprintf("%s|%s|%s|%s", signatureHost, qobuzGDStudioPaddedVersion(), ts9, qobuzGDStudioEscapedValue(value)) - sum := md5.Sum([]byte(signatureBase)) - digest := hex.EncodeToString(sum[:]) - return strings.ToUpper(digest[len(digest)-8:]) -} - -func mapQobuzGDStudioBitrate(quality string) string { - switch strings.TrimSpace(quality) { - case "27", "7": - return "999" - case "", "6": - return "740" - default: - return "320" - } -} - -func (q *QobuzDownloader) DownloadFromGDStudio(trackID int64, quality string, apiURL string) (string, error) { - apiURL = strings.TrimSpace(apiURL) - if apiURL == "" { - apiURL = GetQobuzGDStudioPrimaryAPIURL() - } - - signatureHost := GetQobuzGDStudioSignatureHost(apiURL) - if signatureHost == "" { - return "", fmt.Errorf("GDStudio API URL is invalid: %s", apiURL) - } - - trackIDString := strconv.FormatInt(trackID, 10) - ts9 := q.getQobuzGDStudioTS9(apiURL) - payload := url.Values{ - "types": {"url"}, - "id": {trackIDString}, - "source": {"qobuz"}, - "br": {mapQobuzGDStudioBitrate(quality)}, - "s": {buildQobuzGDStudioSignature(apiURL, trackIDString, ts9)}, - } - - req, err := NewRequestWithDefaultHeaders(http.MethodPost, apiURL, strings.NewReader(payload.Encode())) - if err != nil { - return "", fmt.Errorf("failed to create GDStudio request: %w", err) - } - req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8") - req.Header.Set("Origin", fmt.Sprintf("https://%s", signatureHost)) - req.Header.Set("Referer", fmt.Sprintf("https://%s/", signatureHost)) - - resp, err := q.client.Do(req) - if err != nil { - return "", fmt.Errorf("failed to reach GDStudio: %w", err) - } - defer resp.Body.Close() - - body, err := io.ReadAll(io.LimitReader(resp.Body, 256*1024)) - if err != nil { - return "", fmt.Errorf("failed to read GDStudio response: %w", err) - } - - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("GDStudio returned status %d: %s", resp.StatusCode, previewQobuzResponseBody(body, 256)) - } - - streamURL := extractQobuzStreamingURL(body) - if streamURL == "" { - return "", fmt.Errorf("GDStudio response did not include a stream URL: %s", previewQobuzResponseBody(body, 256)) - } - - return streamURL, nil -} - -func (q *QobuzDownloader) DownloadFromMusicDL(trackID int64, quality string) (string, error) { - if strings.TrimSpace(quality) == "" { - quality = "6" - } - - debugKey, err := getQobuzMusicDLDebugKey() - if err != nil { - return "", fmt.Errorf("failed to decrypt MusicDL debug key: %w", err) - } - - payload, err := json.Marshal(qobuzMusicDLRequest{ - URL: buildQobuzOpenTrackURL(trackID), - Quality: strings.TrimSpace(quality), - }) - if err != nil { - return "", fmt.Errorf("failed to encode MusicDL request: %w", err) - } - - req, err := NewRequestWithDefaultHeaders(http.MethodPost, GetQobuzMusicDLDownloadAPIURL(), bytes.NewReader(payload)) - if err != nil { - return "", fmt.Errorf("failed to create MusicDL request: %w", err) - } - - req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-Debug-Key", debugKey) - - resp, err := q.client.Do(req) - if err != nil { - return "", fmt.Errorf("failed to reach MusicDL: %w", err) - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return "", fmt.Errorf("failed to read MusicDL response: %w", err) - } - - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("MusicDL returned status %d: %s", resp.StatusCode, previewQobuzResponseBody(body, 256)) - } - - var downloadResp qobuzMusicDLResponse - if err := json.Unmarshal(body, &downloadResp); err != nil { - return "", fmt.Errorf("failed to decode MusicDL response: %w (%s)", err, previewQobuzResponseBody(body, 256)) - } - - if !downloadResp.Success { - message := strings.TrimSpace(downloadResp.Error) - if message == "" { - message = strings.TrimSpace(downloadResp.Message) - } - if message == "" { - message = "MusicDL reported failure" - } - return "", fmt.Errorf("%s", message) - } - - downloadURL := strings.TrimSpace(downloadResp.DownloadURL) - if downloadURL == "" { - return "", fmt.Errorf("MusicDL response did not include a download_url") - } - - return downloadURL, nil -} - -func CheckQobuzMusicDLStatusDetailed(client *http.Client) error { - if client == nil { - client = &http.Client{Timeout: 4 * time.Second} - } - - downloader := &QobuzDownloader{client: client} - _, err := downloader.DownloadFromMusicDL(qobuzProbeTrackID, "27") - return err -} - -func CheckQobuzMusicDLStatus(client *http.Client) bool { - return CheckQobuzMusicDLStatusDetailed(client) == nil -} - -func CheckQobuzWJHEStatusDetailed(client *http.Client) error { - if client == nil { - client = &http.Client{Timeout: 4 * time.Second} - } - - downloader := &QobuzDownloader{client: client} - _, err := downloader.DownloadFromWJHE(qobuzProbeTrackID, "27") - return err -} - -func CheckQobuzWJHEStatus(client *http.Client) bool { - return CheckQobuzWJHEStatusDetailed(client) == nil -} - -func CheckQobuzGDStudioAPIStatusDetailed(client *http.Client, apiURL string) error { - if client == nil { - client = &http.Client{Timeout: 4 * time.Second} - } - - downloader := &QobuzDownloader{client: client} - _, err := downloader.DownloadFromGDStudio(qobuzProbeTrackID, "27", apiURL) - return err -} - -func CheckQobuzGDStudioAPIStatus(client *http.Client, apiURL string) bool { - return CheckQobuzGDStudioAPIStatusDetailed(client, apiURL) == nil -} - -func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string, allowFallback bool) (string, error) { - qualityCode := quality - if qualityCode == "" || qualityCode == "5" { - qualityCode = "6" - } - - fmt.Printf("Getting download URL for track ID: %d with requested quality: %s\n", trackID, qualityCode) - - downloadFunc := func(qual string) (string, error) { - attemptMap := make(map[string]qobuzProviderAttempt) - attemptIDs := make([]string, 0, len(GetQobuzDownloadProviderURLs())) - for _, provider := range q.getQobuzDownloadProviders() { - for _, attempt := range provider.Attempts(trackID, qual) { - attemptMap[attempt.ID] = attempt - attemptIDs = append(attemptIDs, attempt.ID) - } - } - - orderedProviderIDs := prioritizeProviders("qobuz", attemptIDs) - orderedProviderIDs = moveQobuzAttemptIDsLast(orderedProviderIDs, GetQobuzMusicDLDownloadAPIURL()) - var lastErr error - for _, providerID := range orderedProviderIDs { - attempt, ok := attemptMap[providerID] - if !ok { - continue - } - - fmt.Printf("Trying Provider: %s (Quality: %s)...\n", attempt.Name, qual) - - url, err := attempt.Download() - if err == nil { - fmt.Printf("✓ Success\n") - recordProviderSuccess("qobuz", attempt.ID) - return url, nil - } - - fmt.Printf("Provider failed: %v\n", err) - recordProviderFailure("qobuz", attempt.ID) - lastErr = err - } - return "", lastErr - } - - url, err := downloadFunc(qualityCode) - if err == nil { - return url, nil - } - - currentQuality := qualityCode - - if currentQuality == "27" && allowFallback { - fmt.Printf("⚠ Download with quality 27 failed, trying fallback to 7 (24-bit Standard)...\n") - url, err := downloadFunc("7") - if err == nil { - fmt.Println("✓ Success with fallback quality 7") - return url, nil - } - - currentQuality = "7" - } - - if currentQuality == "7" && allowFallback { - fmt.Printf("⚠ Download with quality 7 failed, trying fallback to 6 (16-bit Lossless)...\n") - url, err := downloadFunc("6") - if err == nil { - fmt.Println("✓ Success with fallback quality 6") - return url, nil - } - } - - return "", fmt.Errorf("all APIs and fallbacks failed. Last error: %v", err) -} - -func (q *QobuzDownloader) DownloadFile(url, filepath string) error { - fmt.Println("Starting file download...") - - downloadClient := &http.Client{ - Timeout: 5 * time.Minute, - } - - req, err := NewRequestWithDefaultHeaders(http.MethodGet, url, nil) - if err != nil { - return fmt.Errorf("failed to create download request: %w", err) - } - - resp, err := downloadClient.Do(req) - if err != nil { - return fmt.Errorf("failed to download file: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - return fmt.Errorf("download failed with status %d", resp.StatusCode) - } - - fmt.Printf("Creating file: %s\n", filepath) - out, err := os.Create(filepath) - if err != nil { - return fmt.Errorf("failed to create file: %w", err) - } - defer out.Close() - - fmt.Println("Downloading...") - - pw := NewProgressWriter(out) - _, err = io.Copy(pw, resp.Body) - if err != nil { - return fmt.Errorf("failed to write file: %w", err) - } - - fmt.Printf("\rDownloaded: %.2f MB (Complete)\n", float64(pw.GetTotal())/(1024*1024)) - return nil -} - -func (q *QobuzDownloader) DownloadCoverArt(coverURL, filepath string) error { - if coverURL == "" { - return fmt.Errorf("no cover URL provided") - } - - req, err := NewRequestWithDefaultHeaders(http.MethodGet, coverURL, nil) - if err != nil { - return fmt.Errorf("failed to create cover request: %w", err) - } - - resp, err := q.client.Do(req) - if err != nil { - return fmt.Errorf("failed to download cover: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - return fmt.Errorf("cover download failed with status %d", resp.StatusCode) - } - - out, err := os.Create(filepath) - if err != nil { - return fmt.Errorf("failed to create cover file: %w", err) - } - defer out.Close() - - _, err = io.Copy(out, resp.Body) - return err -} - -func buildQobuzFilename(title, artist, album, albumArtist, releaseDate string, trackNumber, discNumber int, format string, includeTrackNumber bool, position int, useAlbumTrackNumber bool, extra ...string) string { - var filename string - isrc := "" - if len(extra) > 0 { - isrc = SanitizeOptionalFilename(extra[0]) - } - - numberToUse := position - if useAlbumTrackNumber && trackNumber > 0 { - numberToUse = trackNumber - } - - year := "" - if len(releaseDate) >= 4 { - year = releaseDate[:4] - } - - if strings.Contains(format, "{") { - filename = format - filename = strings.ReplaceAll(filename, "{title}", title) - filename = strings.ReplaceAll(filename, "{artist}", artist) - filename = strings.ReplaceAll(filename, "{album}", album) - filename = strings.ReplaceAll(filename, "{album_artist}", albumArtist) - filename = strings.ReplaceAll(filename, "{year}", year) - filename = strings.ReplaceAll(filename, "{date}", SanitizeFilename(releaseDate)) - filename = strings.ReplaceAll(filename, "{isrc}", isrc) - - if discNumber > 0 { - filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber)) - } else { - filename = strings.ReplaceAll(filename, "{disc}", "") - } - - if numberToUse > 0 { - filename = strings.ReplaceAll(filename, "{track}", fmt.Sprintf("%02d", numberToUse)) - } else { - - filename = regexp.MustCompile(`\{track\}\.\s*`).ReplaceAllString(filename, "") - filename = regexp.MustCompile(`\{track\}\s*-\s*`).ReplaceAllString(filename, "") - filename = regexp.MustCompile(`\{track\}\s*`).ReplaceAllString(filename, "") - } - } else { - - switch format { - case "artist-title": - filename = fmt.Sprintf("%s - %s", artist, title) - case "title": - filename = title - default: - filename = fmt.Sprintf("%s - %s", title, artist) - } - - if includeTrackNumber && position > 0 { - filename = fmt.Sprintf("%02d. %s", numberToUse, filename) - } - } - - return filename + ".flac" -} - -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, spotifyComposer, metadataSeparator, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) { - var isrc string - if spotifyID != "" { - linkClient := NewSongLinkClient() - resolvedISRC, err := linkClient.GetISRCDirect(spotifyID) - if err != nil { - return "", fmt.Errorf("failed to get ISRC: %v", err) - } - isrc = resolvedISRC - } else { - return "", fmt.Errorf("spotify ID is required for Qobuz download") - } - - return q.DownloadTrackWithISRC(isrc, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, spotifyURL, allowFallback, useFirstArtistOnly, useSingleGenre, embedGenre) -} - -func (q *QobuzDownloader) DownloadTrackWithISRC(isrc, 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, spotifyComposer, metadataSeparator, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) { - fmt.Printf("Fetching track info for ISRC: %s\n", isrc) - - metaChan := make(chan Metadata, 1) - if embedGenre && isrc != "" { - go func() { - if ShouldSkipMusicBrainzMetadataFetch() { - fmt.Println("Skipping MusicBrainz metadata fetch because status check is offline.") - metaChan <- Metadata{} - } else { - fmt.Println("Fetching MusicBrainz metadata...") - if fetchedMeta, err := FetchMusicBrainzMetadata(isrc, 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 err := os.MkdirAll(outputDir, 0755); err != nil { - return "", fmt.Errorf("failed to create output directory: %w", err) - } - } - - track, err := q.searchByISRC(isrc, spotifyTrackName, spotifyArtistName, spotifyAlbumName) - if err != nil { - return "", err - } - - artists := spotifyArtistName - trackTitle := spotifyTrackName - albumTitle := spotifyAlbumName - - fmt.Printf("Found track: %s - %s\n", artists, trackTitle) - fmt.Printf("Album: %s\n", albumTitle) - - qualityInfo := "Standard" - if track.Hires { - if track.MaximumBitDepth > 0 && track.MaximumSamplingRate > 0 { - qualityInfo = fmt.Sprintf("Hi-Res (%d-bit / %.1f kHz)", track.MaximumBitDepth, track.MaximumSamplingRate) - } else if track.MaximumBitDepth > 0 { - qualityInfo = fmt.Sprintf("Hi-Res available (%d-bit)", track.MaximumBitDepth) - } else { - qualityInfo = "Hi-Res available" - } - } - fmt.Printf("Quality: %s\n", qualityInfo) - - fmt.Println("Getting download URL...") - downloadURL, err := q.GetDownloadURL(track.ID, quality, allowFallback) - if err != nil { - return "", fmt.Errorf("failed to get download URL: %w", err) - } - - if downloadURL == "" { - return "", fmt.Errorf("received empty download URL") - } - - urlPreview := downloadURL - if len(downloadURL) > 60 { - urlPreview = downloadURL[:60] + "..." - } - fmt.Printf("Download URL obtained: %s\n", urlPreview) - - safeArtist := sanitizeFilename(artists) - safeAlbumArtist := sanitizeFilename(spotifyAlbumArtist) - - if useFirstArtistOnly { - safeArtist = sanitizeFilename(GetFirstArtist(artists)) - safeAlbumArtist = sanitizeFilename(GetFirstArtist(spotifyAlbumArtist)) - } - - safeTitle := sanitizeFilename(trackTitle) - safeAlbum := sanitizeFilename(albumTitle) - - filename := buildQobuzFilename(safeTitle, safeArtist, safeAlbum, safeAlbumArtist, spotifyReleaseDate, spotifyTrackNumber, spotifyDiscNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber, isrc) - filepath := filepath.Join(outputDir, filename) - filepath, alreadyExists := ResolveOutputPathForDownload(filepath, GetRedownloadWithSuffixSetting()) - if alreadyExists { - fmt.Printf("File already exists: %s (%.2f MB)\n", filepath, float64(mustFileSize(filepath))/(1024*1024)) - return "EXISTS:" + filepath, nil - } - - fmt.Printf("Downloading FLAC file to: %s\n", filepath) - if err := q.DownloadFile(downloadURL, filepath); err != nil { - return "", fmt.Errorf("failed to download file: %w", err) - } - - fmt.Printf("Downloaded: %s\n", filepath) - - coverPath := "" - - if spotifyCoverURL != "" { - coverPath = filepath + ".cover.jpg" - coverClient := NewCoverClient() - if err := coverClient.DownloadCoverToPath(spotifyCoverURL, coverPath, embedMaxQualityCover); err != nil { - fmt.Printf("Warning: Failed to download Spotify cover: %v\n", err) - coverPath = "" - } else { - defer os.Remove(coverPath) - fmt.Println("Spotify cover downloaded") - } - } - - var mbMeta Metadata - if isrc != "" { - mbMeta = <-metaChan - } - - fmt.Println("Embedding metadata and cover art...") - - trackNumberToEmbed := spotifyTrackNumber - if trackNumberToEmbed == 0 { - trackNumberToEmbed = 1 - } - - upc := "" - if identifiers, err := GetSpotifyTrackIdentifiersDirect(spotifyURL); err == nil || identifiers.ISRC != "" || identifiers.UPC != "" { - if strings.TrimSpace(isrc) == "" && strings.TrimSpace(identifiers.ISRC) != "" { - isrc = strings.TrimSpace(identifiers.ISRC) - } - upc = strings.TrimSpace(identifiers.UPC) - } - - metadata := Metadata{ - Title: trackTitle, - Artist: artists, - Album: albumTitle, - AlbumArtist: spotifyAlbumArtist, - Date: spotifyReleaseDate, - TrackNumber: trackNumberToEmbed, - TotalTracks: spotifyTotalTracks, - DiscNumber: spotifyDiscNumber, - TotalDiscs: spotifyTotalDiscs, - URL: spotifyURL, - Comment: spotifyURL, - Copyright: spotifyCopyright, - Publisher: spotifyPublisher, - Composer: spotifyComposer, - Separator: metadataSeparator, - Description: "https://github.com/spotbye/SpotiFLAC", - ISRC: isrc, - UPC: upc, - Genre: mbMeta.Genre, - } - - if err := EmbedMetadata(filepath, metadata, coverPath); err != nil { - return "", fmt.Errorf("failed to embed metadata: %w", err) - } - - fmt.Println("Metadata embedded successfully!") - return filepath, nil -} diff --git a/backend/qobuz_api.go b/backend/qobuz_api.go deleted file mode 100644 index 7815b8a..0000000 --- a/backend/qobuz_api.go +++ /dev/null @@ -1,407 +0,0 @@ -package backend - -import ( - "crypto/md5" - "encoding/hex" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "os" - "path/filepath" - "regexp" - "sort" - "strings" - "sync" - "time" -) - -const ( - qobuzAPIBaseURL = "https://www.qobuz.com/api.json/0.2" - qobuzDefaultAPIAppID = "712109809" - qobuzDefaultAPIAppSecret = "589be88e4538daea11f509d29e4a23b1" - qobuzDefaultUA = DefaultDownloaderUserAgent - qobuzCredentialsCacheFile = "qobuz-api-credentials.json" - qobuzCredentialsCacheTTL = 24 * time.Hour - qobuzCredentialsProbeTrackISRC = "USUM71703861" - qobuzOpenTrackProbeURL = "https://open.qobuz.com/track/1" -) - -var ( - qobuzCredentialsMu sync.Mutex - qobuzCachedCredentials *qobuzAPICredentials - qobuzOpenBundleScriptPattern = regexp.MustCompile(`]+src="([^"]+/js/main\.js|/resources/[^"]+/js/main\.js)"`) - qobuzOpenAPIConfigPattern = regexp.MustCompile(`app_id:"(?P\d{9})",app_secret:"(?P[a-f0-9]{32})"`) -) - -type qobuzAPICredentials struct { - AppID string `json:"app_id"` - AppSecret string `json:"app_secret"` - Source string `json:"source,omitempty"` - FetchedAtUnix int64 `json:"fetched_at_unix"` -} - -type qobuzCredentialProbeResponse struct { - Tracks struct { - Total int `json:"total"` - } `json:"tracks"` -} - -func defaultQobuzAPICredentials() *qobuzAPICredentials { - return &qobuzAPICredentials{ - AppID: qobuzDefaultAPIAppID, - AppSecret: qobuzDefaultAPIAppSecret, - Source: "embedded-default", - FetchedAtUnix: time.Now().Unix(), - } -} - -func qobuzCredentialsCachePath() (string, error) { - appDir, err := GetFFmpegDir() - if err != nil { - return "", err - } - return filepath.Join(appDir, qobuzCredentialsCacheFile), nil -} - -func loadQobuzCachedCredentials() (*qobuzAPICredentials, error) { - cachePath, err := qobuzCredentialsCachePath() - if err != nil { - return nil, err - } - - body, err := os.ReadFile(cachePath) - if err != nil { - if os.IsNotExist(err) { - return nil, nil - } - return nil, fmt.Errorf("failed to read qobuz credentials cache: %w", err) - } - - var creds qobuzAPICredentials - if err := json.Unmarshal(body, &creds); err != nil { - return nil, fmt.Errorf("failed to parse qobuz credentials cache: %w", err) - } - - if strings.TrimSpace(creds.AppID) == "" || strings.TrimSpace(creds.AppSecret) == "" { - return nil, fmt.Errorf("qobuz credentials cache is incomplete") - } - - return &creds, nil -} - -func saveQobuzCachedCredentials(creds *qobuzAPICredentials) error { - if creds == nil { - return fmt.Errorf("qobuz credentials are required") - } - - cachePath, err := qobuzCredentialsCachePath() - if err != nil { - return err - } - - if err := os.MkdirAll(filepath.Dir(cachePath), 0o755); err != nil { - return fmt.Errorf("failed to create qobuz credentials cache directory: %w", err) - } - - body, err := json.MarshalIndent(creds, "", " ") - if err != nil { - return err - } - - if err := os.WriteFile(cachePath, body, 0o644); err != nil { - return fmt.Errorf("failed to write qobuz credentials cache: %w", err) - } - - return nil -} - -func qobuzCredentialsCacheIsFresh(creds *qobuzAPICredentials) bool { - if creds == nil || creds.FetchedAtUnix == 0 || strings.TrimSpace(creds.AppID) == "" || strings.TrimSpace(creds.AppSecret) == "" { - return false - } - return time.Since(time.Unix(creds.FetchedAtUnix, 0)) < qobuzCredentialsCacheTTL -} - -func scrapeQobuzOpenCredentials(client *http.Client) (*qobuzAPICredentials, error) { - req, err := http.NewRequest(http.MethodGet, qobuzOpenTrackProbeURL, nil) - if err != nil { - return nil, err - } - req.Header.Set("User-Agent", qobuzDefaultUA) - - resp, err := client.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to fetch open.qobuz.com shell: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - preview, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) - return nil, fmt.Errorf("open.qobuz.com returned status %d: %s", resp.StatusCode, strings.TrimSpace(string(preview))) - } - - htmlBody, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read open.qobuz.com shell: %w", err) - } - - scriptMatch := qobuzOpenBundleScriptPattern.FindStringSubmatch(string(htmlBody)) - if len(scriptMatch) < 2 { - return nil, fmt.Errorf("qobuz open bundle URL not found") - } - - bundleURL := strings.TrimSpace(scriptMatch[1]) - if strings.HasPrefix(bundleURL, "/") { - bundleURL = "https://open.qobuz.com" + bundleURL - } - if bundleURL == "" { - return nil, fmt.Errorf("qobuz open bundle URL is empty") - } - - bundleReq, err := http.NewRequest(http.MethodGet, bundleURL, nil) - if err != nil { - return nil, err - } - bundleReq.Header.Set("User-Agent", qobuzDefaultUA) - - bundleResp, err := client.Do(bundleReq) - if err != nil { - return nil, fmt.Errorf("failed to fetch qobuz open bundle: %w", err) - } - defer bundleResp.Body.Close() - - if bundleResp.StatusCode != http.StatusOK { - preview, _ := io.ReadAll(io.LimitReader(bundleResp.Body, 512)) - return nil, fmt.Errorf("qobuz open bundle returned status %d: %s", bundleResp.StatusCode, strings.TrimSpace(string(preview))) - } - - bundleBody, err := io.ReadAll(bundleResp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read qobuz open bundle: %w", err) - } - - configMatch := qobuzOpenAPIConfigPattern.FindStringSubmatch(string(bundleBody)) - if len(configMatch) < 3 { - return nil, fmt.Errorf("qobuz api app_id/app_secret pair not found in open bundle") - } - - return &qobuzAPICredentials{ - AppID: strings.TrimSpace(configMatch[1]), - AppSecret: strings.TrimSpace(configMatch[2]), - Source: bundleURL, - FetchedAtUnix: time.Now().Unix(), - }, nil -} - -func qobuzNormalizedPath(path string) string { - return strings.Trim(strings.TrimSpace(path), "/") -} - -func qobuzSignaturePayload(path string, params url.Values, timestamp string, secret string) string { - normalizedPath := strings.ReplaceAll(qobuzNormalizedPath(path), "/", "") - keys := make([]string, 0, len(params)) - for key := range params { - switch key { - case "app_id", "request_ts", "request_sig": - continue - } - keys = append(keys, key) - } - sort.Strings(keys) - - var builder strings.Builder - builder.WriteString(normalizedPath) - for _, key := range keys { - values := params[key] - if len(values) == 0 { - builder.WriteString(key) - continue - } - for _, value := range values { - builder.WriteString(key) - builder.WriteString(value) - } - } - builder.WriteString(timestamp) - builder.WriteString(secret) - return builder.String() -} - -func qobuzRequestSignature(path string, params url.Values, timestamp string, secret string) string { - sum := md5.Sum([]byte(qobuzSignaturePayload(path, params, timestamp, secret))) - return hex.EncodeToString(sum[:]) -} - -func newQobuzSignedRequestWithCredentials(method string, path string, params url.Values, creds *qobuzAPICredentials) (*http.Request, error) { - normalizedPath := qobuzNormalizedPath(path) - if normalizedPath == "" { - return nil, fmt.Errorf("qobuz request path is empty") - } - if creds == nil || strings.TrimSpace(creds.AppID) == "" || strings.TrimSpace(creds.AppSecret) == "" { - return nil, fmt.Errorf("qobuz credentials are incomplete") - } - - clonedParams := url.Values{} - for key, values := range params { - for _, value := range values { - clonedParams.Add(key, value) - } - } - - timestamp := fmt.Sprintf("%d", time.Now().Unix()) - clonedParams.Set("app_id", creds.AppID) - clonedParams.Set("request_ts", timestamp) - clonedParams.Set("request_sig", qobuzRequestSignature(normalizedPath, params, timestamp, creds.AppSecret)) - - reqURL := fmt.Sprintf("%s/%s?%s", qobuzAPIBaseURL, normalizedPath, clonedParams.Encode()) - req, err := http.NewRequest(method, reqURL, nil) - if err != nil { - return nil, err - } - - req.Header.Set("User-Agent", qobuzDefaultUA) - req.Header.Set("Accept", "application/json") - req.Header.Set("X-App-Id", creds.AppID) - - return req, nil -} - -func qobuzCredentialsSupportSignedMetadata(client *http.Client, creds *qobuzAPICredentials) bool { - if creds == nil { - return false - } - - req, err := newQobuzSignedRequestWithCredentials(http.MethodGet, "track/search", url.Values{ - "query": {qobuzCredentialsProbeTrackISRC}, - "limit": {"1"}, - }, creds) - if err != nil { - return false - } - - resp, err := client.Do(req) - if err != nil { - return false - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return false - } - - var payload qobuzCredentialProbeResponse - if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { - return false - } - - return payload.Tracks.Total > 0 -} - -func getQobuzAPICredentials(forceRefresh bool) (*qobuzAPICredentials, error) { - qobuzCredentialsMu.Lock() - defer qobuzCredentialsMu.Unlock() - - if !forceRefresh && qobuzCredentialsCacheIsFresh(qobuzCachedCredentials) { - return qobuzCachedCredentials, nil - } - - cachedFromDisk, diskErr := loadQobuzCachedCredentials() - if diskErr != nil { - fmt.Printf("Warning: failed to read Qobuz credentials cache: %v\n", diskErr) - } - if !forceRefresh && qobuzCredentialsCacheIsFresh(cachedFromDisk) { - qobuzCachedCredentials = cachedFromDisk - return qobuzCachedCredentials, nil - } - - client := &http.Client{Timeout: 30 * time.Second} - scrapedCreds, scrapeErr := scrapeQobuzOpenCredentials(client) - if scrapeErr == nil { - if qobuzCredentialsSupportSignedMetadata(client, scrapedCreds) { - qobuzCachedCredentials = scrapedCreds - if err := saveQobuzCachedCredentials(scrapedCreds); err != nil { - fmt.Printf("Warning: failed to write Qobuz credentials cache: %v\n", err) - } - fmt.Printf("Loaded fresh Qobuz credentials from %s (app_id=%s)\n", scrapedCreds.Source, scrapedCreds.AppID) - return qobuzCachedCredentials, nil - } - scrapeErr = fmt.Errorf("scraped qobuz credentials did not pass validation") - } - - if cachedFromDisk != nil { - qobuzCachedCredentials = cachedFromDisk - fmt.Printf("Warning: failed to refresh Qobuz credentials, using cached credentials: %v\n", scrapeErr) - return qobuzCachedCredentials, nil - } - - if qobuzCachedCredentials != nil { - fmt.Printf("Warning: failed to refresh Qobuz credentials, using in-memory credentials: %v\n", scrapeErr) - return qobuzCachedCredentials, nil - } - - fallback := defaultQobuzAPICredentials() - qobuzCachedCredentials = fallback - if scrapeErr != nil { - fmt.Printf("Warning: failed to refresh Qobuz credentials, using embedded fallback: %v\n", scrapeErr) - } - return qobuzCachedCredentials, nil -} - -func qobuzShouldRefreshCredentials(statusCode int) bool { - return statusCode == http.StatusBadRequest || statusCode == http.StatusUnauthorized -} - -func newQobuzSignedRequest(method string, path string, params url.Values) (*http.Request, error) { - creds, err := getQobuzAPICredentials(false) - if err != nil { - return nil, err - } - return newQobuzSignedRequestWithCredentials(method, path, params, creds) -} - -func doQobuzSignedRequest(method string, path string, params url.Values, client *http.Client) (*http.Response, error) { - if client == nil { - client = &http.Client{Timeout: 20 * time.Second} - } - - call := func(forceRefresh bool) (*http.Response, error) { - creds, err := getQobuzAPICredentials(forceRefresh) - if err != nil { - return nil, err - } - req, err := newQobuzSignedRequestWithCredentials(method, path, params, creds) - if err != nil { - return nil, err - } - return client.Do(req) - } - - resp, err := call(false) - if err != nil { - return nil, err - } - - if qobuzShouldRefreshCredentials(resp.StatusCode) { - resp.Body.Close() - return call(true) - } - - return resp, nil -} - -func doQobuzSignedJSONRequest(path string, params url.Values, target interface{}) error { - resp, err := doQobuzSignedRequest(http.MethodGet, path, params, &http.Client{Timeout: 20 * time.Second}) - if err != nil { - return err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) - return fmt.Errorf("qobuz request failed: HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(snippet))) - } - - return json.NewDecoder(resp.Body).Decode(target) -} diff --git a/backend/qobuz_providers.go b/backend/qobuz_providers.go deleted file mode 100644 index e00e835..0000000 --- a/backend/qobuz_providers.go +++ /dev/null @@ -1,106 +0,0 @@ -package backend - -type qobuzDownloadProvider interface { - Name() string - Attempts(trackID int64, quality string) []qobuzProviderAttempt -} - -type qobuzProviderAttempt struct { - Name string - ID string - Download func() (string, error) -} - -type QobuzProviderWJHE struct { - downloader *QobuzDownloader -} - -func (p QobuzProviderWJHE) Name() string { - return "QobuzProviderWJHE" -} - -func (p QobuzProviderWJHE) Attempts(trackID int64, quality string) []qobuzProviderAttempt { - return []qobuzProviderAttempt{ - { - Name: p.Name(), - ID: GetQobuzWJHEStreamAPIURL(), - Download: func() (string, error) { - return p.downloader.DownloadFromWJHE(trackID, quality) - }, - }, - } -} - -type QobuzProviderMusicDL struct { - downloader *QobuzDownloader -} - -func (p QobuzProviderMusicDL) Name() string { - return "QobuzProviderMusicDL" -} - -func (p QobuzProviderMusicDL) Attempts(trackID int64, quality string) []qobuzProviderAttempt { - return []qobuzProviderAttempt{ - { - Name: p.Name(), - ID: GetQobuzMusicDLDownloadAPIURL(), - Download: func() (string, error) { - return p.downloader.DownloadFromMusicDL(trackID, quality) - }, - }, - } -} - -type QobuzProviderGDStudio struct { - downloader *QobuzDownloader -} - -func (p QobuzProviderGDStudio) Name() string { - return "QobuzProviderGDStudio" -} - -func (p QobuzProviderGDStudio) Attempts(trackID int64, quality string) []qobuzProviderAttempt { - attempts := make([]qobuzProviderAttempt, 0, len(GetQobuzGDStudioAPIURLs())) - for _, apiURL := range GetQobuzGDStudioAPIURLs() { - currentAPIURL := apiURL - attempts = append(attempts, qobuzProviderAttempt{ - Name: p.Name(), - ID: currentAPIURL, - Download: func() (string, error) { - return p.downloader.DownloadFromGDStudio(trackID, quality, currentAPIURL) - }, - }) - } - return attempts -} - -func (q *QobuzDownloader) getQobuzDownloadProviders() []qobuzDownloadProvider { - return []qobuzDownloadProvider{ - QobuzProviderWJHE{downloader: q}, - QobuzProviderGDStudio{downloader: q}, - QobuzProviderMusicDL{downloader: q}, - } -} - -func moveQobuzAttemptIDsLast(providerIDs []string, lastIDs ...string) []string { - if len(providerIDs) == 0 || len(lastIDs) == 0 { - return append([]string(nil), providerIDs...) - } - - lastIDSet := make(map[string]struct{}, len(lastIDs)) - for _, providerID := range lastIDs { - lastIDSet[providerID] = struct{}{} - } - - ordered := make([]string, 0, len(providerIDs)) - trailing := make([]string, 0, len(providerIDs)) - for _, providerID := range providerIDs { - if _, ok := lastIDSet[providerID]; ok { - trailing = append(trailing, providerID) - continue - } - ordered = append(ordered, providerID) - } - - return append(ordered, trailing...) -} diff --git a/backend/recent_fetches.go b/backend/recent_fetches.go deleted file mode 100644 index 34d30fd..0000000 --- a/backend/recent_fetches.go +++ /dev/null @@ -1,91 +0,0 @@ -package backend - -import ( - "encoding/json" - "os" - "path/filepath" - "strings" - "sync" -) - -const recentFetchesFileName = "recent_fetches.json" - -type RecentFetchItem struct { - ID string `json:"id"` - URL string `json:"url"` - Type string `json:"type"` - Name string `json:"name"` - Artist string `json:"artist"` - Image string `json:"image"` - Timestamp int64 `json:"timestamp"` -} - -var ( - recentFetchesMu sync.Mutex - recentFetchesDirResolver = GetFFmpegDir -) - -func recentFetchesFilePath() (string, error) { - baseDir, err := recentFetchesDirResolver() - if err != nil { - return "", err - } - if err := os.MkdirAll(baseDir, 0o755); err != nil { - return "", err - } - return filepath.Join(baseDir, recentFetchesFileName), nil -} - -func LoadRecentFetches() ([]RecentFetchItem, error) { - recentFetchesMu.Lock() - defer recentFetchesMu.Unlock() - - filePath, err := recentFetchesFilePath() - if err != nil { - return nil, err - } - - data, err := os.ReadFile(filePath) - if err != nil { - if os.IsNotExist(err) { - return []RecentFetchItem{}, nil - } - return nil, err - } - - if strings.TrimSpace(string(data)) == "" { - return []RecentFetchItem{}, nil - } - - var items []RecentFetchItem - if err := json.Unmarshal(data, &items); err != nil { - return nil, err - } - - if items == nil { - return []RecentFetchItem{}, nil - } - - return items, nil -} - -func SaveRecentFetches(items []RecentFetchItem) error { - recentFetchesMu.Lock() - defer recentFetchesMu.Unlock() - - filePath, err := recentFetchesFilePath() - if err != nil { - return err - } - - if items == nil { - items = []RecentFetchItem{} - } - - data, err := json.MarshalIndent(items, "", " ") - if err != nil { - return err - } - - return os.WriteFile(filePath, data, 0o644) -} diff --git a/backend/resample.go b/backend/resample.go deleted file mode 100644 index 9d53ec1..0000000 --- a/backend/resample.go +++ /dev/null @@ -1,223 +0,0 @@ -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 -} diff --git a/backend/songlink.go b/backend/songlink.go deleted file mode 100644 index 8113a21..0000000 --- a/backend/songlink.go +++ /dev/null @@ -1,505 +0,0 @@ -package backend - -import ( - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "regexp" - "strings" - "time" -) - -const songLinkUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36" - -var ( - isrcPattern = regexp.MustCompile(`\b([A-Z]{2}[A-Z0-9]{3}\d{7})\b`) - amazonAlbumTrackPath = regexp.MustCompile(`/albums/[A-Z0-9]{10}/(B[0-9A-Z]{9})`) - amazonTrackPath = regexp.MustCompile(`/tracks/(B[0-9A-Z]{9})`) -) - -type SongLinkClient struct { - client *http.Client -} - -type SongLinkURLs struct { - TidalURL string `json:"tidal_url"` - AmazonURL string `json:"amazon_url"` - ISRC string `json:"isrc"` -} - -type TrackAvailability struct { - SpotifyID string `json:"spotify_id"` - Tidal bool `json:"tidal"` - Amazon bool `json:"amazon"` - Qobuz bool `json:"qobuz"` - Deezer bool `json:"deezer"` - TidalURL string `json:"tidal_url,omitempty"` - AmazonURL string `json:"amazon_url,omitempty"` - QobuzURL string `json:"qobuz_url,omitempty"` - DeezerURL string `json:"deezer_url,omitempty"` -} - -type songLinkAPIResponse struct { - LinksByPlatform map[string]struct { - URL string `json:"url"` - } `json:"linksByPlatform"` -} - -type qobuzAvailabilityTrack struct { - ID int64 `json:"id"` - Album struct { - ID string `json:"id"` - Title string `json:"title"` - URL string `json:"url"` - RelativeURL string `json:"relative_url"` - } `json:"album"` -} - -func NewSongLinkClient() *SongLinkClient { - return &SongLinkClient{ - client: &http.Client{ - Timeout: 30 * time.Second, - }, - } -} - -func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string, region string) (*SongLinkURLs, error) { - links, err := s.resolveSpotifyTrackLinks(spotifyTrackID, region) - if err != nil && (links == nil || (links.TidalURL == "" && links.AmazonURL == "")) { - return nil, err - } - - urls := &SongLinkURLs{} - if links != nil { - urls.TidalURL = links.TidalURL - urls.AmazonURL = normalizeAmazonMusicURL(links.AmazonURL) - urls.ISRC = links.ISRC - } - - if urls.TidalURL == "" && urls.AmazonURL == "" { - if err != nil { - return nil, err - } - return nil, fmt.Errorf("no streaming URLs found") - } - - return urls, nil -} - -func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string) (*TrackAvailability, error) { - links, err := s.resolveSpotifyTrackLinks(spotifyTrackID, "") - - availability := &TrackAvailability{ - SpotifyID: spotifyTrackID, - } - - if links != nil { - availability.TidalURL = links.TidalURL - availability.AmazonURL = normalizeAmazonMusicURL(links.AmazonURL) - availability.DeezerURL = normalizeDeezerTrackURL(links.DeezerURL) - availability.Tidal = availability.TidalURL != "" - availability.Amazon = availability.AmazonURL != "" - availability.Deezer = availability.DeezerURL != "" - } - - isrc := "" - if links != nil { - isrc = strings.TrimSpace(links.ISRC) - } - - if isrc == "" && availability.DeezerURL != "" { - if resolvedISRC, deezerErr := getDeezerISRC(availability.DeezerURL); deezerErr == nil { - isrc = resolvedISRC - } - } - - if isrc == "" { - if fallbackISRC, fallbackErr := s.lookupSpotifyISRC(spotifyTrackID); fallbackErr == nil { - isrc = fallbackISRC - } else if err == nil { - err = fallbackErr - } - } - - if isrc != "" { - availability.Qobuz, availability.QobuzURL = checkQobuzAvailability(isrc) - } - - if availability.Tidal || availability.Amazon || availability.Deezer || availability.Qobuz { - return availability, nil - } - - if err != nil { - return availability, err - } - - return availability, fmt.Errorf("no platforms found") -} - -func qobuzNormalizeRelativeURL(rawURL string) string { - rawURL = strings.TrimSpace(rawURL) - if rawURL == "" { - return "" - } - if strings.HasPrefix(rawURL, "http://") || strings.HasPrefix(rawURL, "https://") { - return rawURL - } - if strings.HasPrefix(rawURL, "/") { - return "https://www.qobuz.com" + rawURL - } - return "https://www.qobuz.com/" + rawURL -} - -func qobuzSlugifySegment(value string) string { - value = strings.ToLower(strings.TrimSpace(value)) - if value == "" { - return "" - } - - var builder strings.Builder - lastDash := false - for _, r := range value { - switch { - case r >= 'a' && r <= 'z', r >= '0' && r <= '9': - builder.WriteRune(r) - lastDash = false - default: - if !lastDash { - builder.WriteByte('-') - lastDash = true - } - } - } - - return strings.Trim(builder.String(), "-") -} - -func qobuzAlbumSlugURL(albumTitle string, albumID string) string { - albumID = strings.TrimSpace(albumID) - if albumID == "" { - return "" - } - - slug := qobuzSlugifySegment(albumTitle) - if slug == "" { - return fmt.Sprintf("https://www.qobuz.com/album/%s", albumID) - } - - return fmt.Sprintf("https://www.qobuz.com/album/%s/%s", slug, albumID) -} - -func checkQobuzAvailability(isrc string) (bool, string) { - var searchResp struct { - Tracks struct { - Total int `json:"total"` - Items []qobuzAvailabilityTrack `json:"items"` - } `json:"tracks"` - } - - if err := doQobuzSignedJSONRequest("track/search", url.Values{ - "query": {strings.TrimSpace(isrc)}, - "limit": {"1"}, - }, &searchResp); err != nil { - return false, "" - } - - if searchResp.Tracks.Total == 0 || len(searchResp.Tracks.Items) == 0 { - return false, "" - } - - item := searchResp.Tracks.Items[0] - qobuzURL := strings.TrimSpace(item.Album.URL) - if qobuzURL == "" { - qobuzURL = qobuzNormalizeRelativeURL(item.Album.RelativeURL) - } - if qobuzURL == "" { - qobuzURL = qobuzAlbumSlugURL(item.Album.Title, item.Album.ID) - } - if qobuzURL == "" && item.ID > 0 { - qobuzURL = fmt.Sprintf("https://www.qobuz.com/us-en/track/%d", item.ID) - } - - return true, qobuzURL -} - -func (s *SongLinkClient) GetDeezerURLFromSpotify(spotifyTrackID string) (string, error) { - links, err := s.resolveSpotifyTrackLinks(spotifyTrackID, "") - if links != nil && links.DeezerURL != "" { - deezerURL := normalizeDeezerTrackURL(links.DeezerURL) - fmt.Printf("Found Deezer URL: %s\n", deezerURL) - return deezerURL, nil - } - - isrc := "" - if links != nil { - isrc = strings.TrimSpace(links.ISRC) - } - if isrc == "" { - fallbackISRC, lookupErr := s.lookupSpotifyISRC(spotifyTrackID) - if lookupErr == nil { - isrc = fallbackISRC - } else if err == nil { - err = lookupErr - } - } - - if isrc != "" { - deezerURL, deezerErr := s.lookupDeezerTrackURLByISRC(isrc) - if deezerErr == nil { - fmt.Printf("Found Deezer URL: %s\n", deezerURL) - return deezerURL, nil - } - if err == nil { - err = deezerErr - } - } - - if err != nil { - return "", err - } - return "", fmt.Errorf("deezer link not found") -} - -func getDeezerISRC(deezerURL string) (string, error) { - trackID, err := extractDeezerTrackID(deezerURL) - if err != nil { - return "", err - } - - apiURL := fmt.Sprintf("https://api.deezer.com/track/%s", trackID) - - client := &http.Client{Timeout: 10 * time.Second} - resp, err := client.Get(apiURL) - if err != nil { - return "", fmt.Errorf("failed to call Deezer API: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("Deezer API returned status %d", resp.StatusCode) - } - - var deezerTrack struct { - ID int64 `json:"id"` - ISRC string `json:"isrc"` - Title string `json:"title"` - } - if err := json.NewDecoder(resp.Body).Decode(&deezerTrack); err != nil { - return "", fmt.Errorf("failed to decode Deezer API response: %w", err) - } - - if deezerTrack.ISRC == "" { - return "", fmt.Errorf("ISRC not found in Deezer API response for track %s", trackID) - } - - fmt.Printf("Found ISRC from Deezer: %s (track: %s)\n", deezerTrack.ISRC, deezerTrack.Title) - return strings.ToUpper(strings.TrimSpace(deezerTrack.ISRC)), nil -} - -func (s *SongLinkClient) GetISRC(spotifyID string) (string, error) { - links, err := s.resolveSpotifyTrackLinks(spotifyID, "") - if links != nil && links.ISRC != "" { - return links.ISRC, nil - } - - if links != nil && links.DeezerURL != "" { - if isrc, deezerErr := getDeezerISRC(links.DeezerURL); deezerErr == nil { - return isrc, nil - } - } - - isrc, lookupErr := s.lookupSpotifyISRC(spotifyID) - if lookupErr == nil && isrc != "" { - return isrc, nil - } - - if err != nil && lookupErr != nil { - return "", fmt.Errorf("%v | %v", err, lookupErr) - } - if err != nil { - return "", err - } - if lookupErr != nil { - return "", lookupErr - } - - return "", fmt.Errorf("ISRC not found") -} - -func (s *SongLinkClient) GetISRCDirect(spotifyID string) (string, error) { - return s.lookupSpotifyISRC(spotifyID) -} - -func (s *SongLinkClient) fetchSongLinkLinksByURL(rawURL string, region string) (*songLinkAPIResponse, error) { - apiURL := fmt.Sprintf("https://api.song.link/v1-alpha.1/links?url=%s", url.QueryEscape(rawURL)) - if region != "" { - apiURL += fmt.Sprintf("&userCountry=%s", url.QueryEscape(region)) - } - - req, err := http.NewRequest(http.MethodGet, apiURL, nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - req.Header.Set("User-Agent", songLinkUserAgent) - - resp, err := s.client.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to call song.link: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - bodyPreview, _ := io.ReadAll(io.LimitReader(resp.Body, 256)) - return nil, fmt.Errorf("song.link returned status %d (%s)", resp.StatusCode, strings.TrimSpace(string(bodyPreview))) - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read song.link response: %w", err) - } - if len(body) == 0 { - return nil, fmt.Errorf("song.link returned empty response") - } - - var parsed songLinkAPIResponse - if err := json.Unmarshal(body, &parsed); err != nil { - bodyStr := string(body) - if len(bodyStr) > 200 { - bodyStr = bodyStr[:200] + "..." - } - return nil, fmt.Errorf("failed to decode song.link response: %w (response: %s)", err, bodyStr) - } - - return &parsed, nil -} - -func (s *SongLinkClient) lookupDeezerTrackURLByISRC(isrc string) (string, error) { - apiURL := fmt.Sprintf("https://api.deezer.com/track/isrc:%s", strings.ToUpper(strings.TrimSpace(isrc))) - - req, err := http.NewRequest(http.MethodGet, apiURL, nil) - if err != nil { - return "", fmt.Errorf("failed to create request: %w", err) - } - req.Header.Set("User-Agent", songLinkUserAgent) - - resp, err := s.client.Do(req) - if err != nil { - return "", fmt.Errorf("failed to call Deezer ISRC API: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("Deezer ISRC API returned status %d", resp.StatusCode) - } - - var payload struct { - ID int64 `json:"id"` - ISRC string `json:"isrc"` - Link string `json:"link"` - } - if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { - return "", fmt.Errorf("failed to decode Deezer ISRC response: %w", err) - } - - if payload.Link != "" { - return normalizeDeezerTrackURL(payload.Link), nil - } - if payload.ID > 0 { - return normalizeDeezerTrackURL(fmt.Sprintf("https://www.deezer.com/track/%d", payload.ID)), nil - } - - return "", fmt.Errorf("deezer track link not found for ISRC %s", isrc) -} - -func mergeSongLinkResponse(links *resolvedTrackLinks, resp *songLinkAPIResponse) { - if resp == nil { - return - } - - if link, ok := resp.LinksByPlatform["tidal"]; ok && link.URL != "" && links.TidalURL == "" { - links.TidalURL = strings.TrimSpace(link.URL) - fmt.Println("✓ Tidal URL found") - } - - if link, ok := resp.LinksByPlatform["amazonMusic"]; ok && link.URL != "" && links.AmazonURL == "" { - links.AmazonURL = normalizeAmazonMusicURL(link.URL) - fmt.Println("✓ Amazon URL found") - } - - if link, ok := resp.LinksByPlatform["deezer"]; ok && link.URL != "" && links.DeezerURL == "" { - links.DeezerURL = normalizeDeezerTrackURL(link.URL) - fmt.Println("✓ Deezer URL found") - } -} - -func normalizeAmazonMusicURL(rawURL string) string { - amazonURL := strings.TrimSpace(rawURL) - if amazonURL == "" { - return "" - } - - if strings.Contains(amazonURL, "trackAsin=") { - parts := strings.Split(amazonURL, "trackAsin=") - if len(parts) > 1 { - trackAsin := strings.Split(parts[1], "&")[0] - if trackAsin != "" { - return fmt.Sprintf("https://music.amazon.com/tracks/%s?musicTerritory=US", trackAsin) - } - } - } - - if match := amazonAlbumTrackPath.FindStringSubmatch(amazonURL); len(match) > 1 { - return fmt.Sprintf("https://music.amazon.com/tracks/%s?musicTerritory=US", match[1]) - } - - if match := amazonTrackPath.FindStringSubmatch(amazonURL); len(match) > 1 { - return fmt.Sprintf("https://music.amazon.com/tracks/%s?musicTerritory=US", match[1]) - } - - return "" -} - -func normalizeDeezerTrackURL(rawURL string) string { - trackID, err := extractDeezerTrackID(rawURL) - if err != nil { - return strings.TrimSpace(rawURL) - } - return fmt.Sprintf("https://www.deezer.com/track/%s", trackID) -} - -func extractDeezerTrackID(rawURL string) (string, error) { - cleanURL := strings.TrimSpace(rawURL) - if cleanURL == "" { - return "", fmt.Errorf("empty Deezer URL") - } - - parts := strings.Split(cleanURL, "/track/") - if len(parts) < 2 { - return "", fmt.Errorf("could not extract track ID from Deezer URL: %s", rawURL) - } - - trackID := strings.Split(parts[1], "?")[0] - trackID = strings.Trim(trackID, "/ ") - if trackID == "" { - return "", fmt.Errorf("could not extract track ID from Deezer URL: %s", rawURL) - } - - return trackID, nil -} - -func hasAnySongLinkData(links *resolvedTrackLinks) bool { - if links == nil { - return false - } - return links.TidalURL != "" || links.AmazonURL != "" || links.DeezerURL != "" -} - -func firstISRCMatch(body string) string { - match := isrcPattern.FindStringSubmatch(strings.ToUpper(body)) - if len(match) < 2 { - return "" - } - return strings.TrimSpace(match[1]) -} diff --git a/backend/songstats.go b/backend/songstats.go deleted file mode 100644 index 7c16b8b..0000000 --- a/backend/songstats.go +++ /dev/null @@ -1,128 +0,0 @@ -package backend - -import ( - "encoding/json" - "fmt" - "html" - "io" - "net/http" - "regexp" - "strings" -) - -var songstatsScriptPattern = regexp.MustCompile(`(?is)]+type=["']application/ld\+json["'][^>]*>(.*?)`) - -func (s *SongLinkClient) populateLinksFromSongstats(links *resolvedTrackLinks, isrc string) error { - pageURL := fmt.Sprintf("https://songstats.com/%s?ref=ISRCFinder", strings.ToUpper(strings.TrimSpace(isrc))) - - req, err := http.NewRequest(http.MethodGet, pageURL, nil) - if err != nil { - return fmt.Errorf("failed to create request: %w", err) - } - req.Header.Set("User-Agent", songLinkUserAgent) - - resp, err := s.client.Do(req) - if err != nil { - return fmt.Errorf("failed to fetch Songstats page: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("Songstats returned status %d", resp.StatusCode) - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return fmt.Errorf("failed to read Songstats response: %w", err) - } - - matches := songstatsScriptPattern.FindAllStringSubmatch(string(body), -1) - if len(matches) == 0 { - return fmt.Errorf("Songstats JSON-LD not found") - } - - found := false - for _, match := range matches { - if len(match) < 2 { - continue - } - - scriptBody := strings.TrimSpace(html.UnescapeString(match[1])) - if scriptBody == "" { - continue - } - - var payload interface{} - if err := json.Unmarshal([]byte(scriptBody), &payload); err != nil { - continue - } - - before := *links - collectSongstatsLinks(payload, links) - if *links != before { - found = true - } - } - - if !found && !hasAnySongLinkData(links) { - return fmt.Errorf("no platform links found in Songstats") - } - - return nil -} - -func collectSongstatsLinks(value interface{}, links *resolvedTrackLinks) { - switch typed := value.(type) { - case map[string]interface{}: - if sameAs, ok := typed["sameAs"]; ok { - applySongstatsSameAs(sameAs, links) - } - for _, nested := range typed { - collectSongstatsLinks(nested, links) - } - case []interface{}: - for _, nested := range typed { - collectSongstatsLinks(nested, links) - } - } -} - -func applySongstatsSameAs(value interface{}, links *resolvedTrackLinks) { - switch typed := value.(type) { - case string: - assignSongstatsLink(typed, links) - case []interface{}: - for _, item := range typed { - if link, ok := item.(string); ok { - assignSongstatsLink(link, links) - } - } - } -} - -func assignSongstatsLink(rawLink string, links *resolvedTrackLinks) { - link := strings.TrimSpace(rawLink) - if link == "" { - return - } - - switch { - case strings.Contains(link, "listen.tidal.com/track"): - if links.TidalURL == "" { - links.TidalURL = link - fmt.Println("✓ Tidal URL found via Songstats") - } - case strings.Contains(link, "music.amazon.com"): - if links.AmazonURL == "" { - if normalized := normalizeAmazonMusicURL(link); normalized != "" { - links.AmazonURL = normalized - fmt.Println("✓ Amazon URL found via Songstats") - } - } - case strings.Contains(link, "deezer.com"): - if links.DeezerURL == "" { - links.DeezerURL = normalizeDeezerTrackURL(link) - fmt.Println("✓ Deezer URL found via Songstats") - } - } -} diff --git a/backend/soundplate.go b/backend/soundplate.go deleted file mode 100644 index bce9c94..0000000 --- a/backend/soundplate.go +++ /dev/null @@ -1,95 +0,0 @@ -package backend - -import ( - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "strings" -) - -const ( - soundplateSpotifyAPIURL = "https://phpstack-822472-6184058.cloudwaysapps.com/api/spotify.php" - soundplateRefererURL = "https://phpstack-822472-6184058.cloudwaysapps.com/?" - soundplateUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36" -) - -type soundplateSpotifyResponse struct { - Name string `json:"name"` - Artist string `json:"artist"` - Album string `json:"album"` - AlbumType string `json:"album_type"` - ArtworkURL string `json:"artwork_url"` - ISRC string `json:"isrc"` - Year string `json:"year"` - SpotifyURL string `json:"spotify_url"` -} - -func (s *SongLinkClient) lookupSpotifyISRCViaSoundplate(spotifyTrackID string) (string, string, error) { - normalizedTrackID, err := extractSpotifyTrackID(spotifyTrackID) - if err != nil { - return "", "", err - } - - spotifyTrackURL := fmt.Sprintf("https://open.spotify.com/track/%s", normalizedTrackID) - query := url.Values{} - query.Set("q", spotifyTrackURL) - - req, err := http.NewRequest(http.MethodGet, soundplateSpotifyAPIURL+"?"+query.Encode(), nil) - if err != nil { - return "", "", fmt.Errorf("failed to create Soundplate ISRC request: %w", err) - } - req.Header.Set("User-Agent", soundplateUserAgent) - req.Header.Set("Accept", "*/*") - req.Header.Set("Referer", soundplateRefererURL) - req.Header.Set("Accept-Language", "en-US,en;q=0.9,id;q=0.8") - req.Header.Set("Sec-CH-UA", "\"Chromium\";v=\"146\", \"Not-A.Brand\";v=\"24\", \"Google Chrome\";v=\"146\"") - req.Header.Set("Sec-CH-UA-Mobile", "?0") - req.Header.Set("Sec-CH-UA-Platform", "\"Windows\"") - req.Header.Set("Sec-Fetch-Dest", "empty") - req.Header.Set("Sec-Fetch-Mode", "cors") - req.Header.Set("Sec-Fetch-Site", "same-origin") - req.Header.Set("Priority", "u=1, i") - - resp, err := s.client.Do(req) - if err != nil { - return "", "", fmt.Errorf("Soundplate ISRC request failed: %w", err) - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return "", "", fmt.Errorf("failed to read Soundplate ISRC response: %w", err) - } - - if resp.StatusCode != http.StatusOK { - bodyPreview := strings.TrimSpace(string(body)) - if len(bodyPreview) > 256 { - bodyPreview = bodyPreview[:256] - } - return "", "", fmt.Errorf("Soundplate ISRC returned status %d (%s)", resp.StatusCode, bodyPreview) - } - - var payload soundplateSpotifyResponse - if err := json.Unmarshal(body, &payload); err != nil { - return "", "", fmt.Errorf("failed to decode Soundplate ISRC response: %w", err) - } - - isrc := firstISRCMatch(payload.ISRC) - if isrc == "" { - isrc = firstISRCMatch(string(body)) - } - if isrc == "" { - return "", "", fmt.Errorf("ISRC missing in Soundplate response") - } - - resolvedTrackID := "" - if payload.SpotifyURL != "" { - if trackID, err := extractSpotifyTrackID(payload.SpotifyURL); err == nil { - resolvedTrackID = trackID - } - } - - return isrc, resolvedTrackID, nil -} diff --git a/backend/spotfetch.go b/backend/spotfetch.go deleted file mode 100644 index f168005..0000000 --- a/backend/spotfetch.go +++ /dev/null @@ -1,1753 +0,0 @@ -package backend - -import ( - "bytes" - "encoding/base64" - "encoding/json" - "errors" - "fmt" - "html" - "io" - "net/http" - "regexp" - "strconv" - "strings" - "time" - - "sort" -) - -var SpotifyError = errors.New("spotify error") - -type SpotifyClient struct { - client *http.Client - accessToken string - clientToken string - clientID string - deviceID string - clientVersion string - cookies map[string]string -} - -func NewSpotifyClient() *SpotifyClient { - return &SpotifyClient{ - client: &http.Client{Timeout: 30 * time.Second}, - cookies: make(map[string]string), - } -} - -func (c *SpotifyClient) generateTOTP() (string, int, error) { - return generateSpotifyTOTP(time.Now()) -} - -func (c *SpotifyClient) getAccessToken() error { - totpCode, version, err := c.generateTOTP() - if err != nil { - return err - } - - req, err := http.NewRequest("GET", "https://open.spotify.com/api/token", nil) - if err != nil { - return err - } - - q := req.URL.Query() - q.Add("reason", "init") - q.Add("productType", "web-player") - q.Add("totp", totpCode) - q.Add("totpVer", strconv.Itoa(version)) - q.Add("totpServer", totpCode) - 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/145.0.0.0 Safari/537.36") - req.Header.Set("Content-Type", "application/json;charset=UTF-8") - - resp, err := c.client.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - return fmt.Errorf("%w: access token request failed: HTTP %d", SpotifyError, resp.StatusCode) - } - - var data map[string]interface{} - if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { - return err - } - - c.accessToken = getString(data, "accessToken") - c.clientID = getString(data, "clientId") - - for _, cookie := range resp.Cookies() { - if cookie.Name == "sp_t" { - c.deviceID = cookie.Value - } - c.cookies[cookie.Name] = cookie.Value - } - - return nil -} - -func (c *SpotifyClient) getSessionInfo() error { - req, err := http.NewRequest("GET", "https://open.spotify.com", 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/145.0.0.0 Safari/537.36") - - for name, value := range c.cookies { - req.AddCookie(&http.Cookie{Name: name, Value: value}) - } - - resp, err := c.client.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - return fmt.Errorf("%w: session initialization failed: HTTP %d", SpotifyError, resp.StatusCode) - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return err - } - - re := regexp.MustCompile(``) - matches := re.FindStringSubmatch(string(body)) - if len(matches) > 1 { - decoded, err := base64.StdEncoding.DecodeString(matches[1]) - if err == nil { - var cfg map[string]interface{} - if json.Unmarshal(decoded, &cfg) == nil { - c.clientVersion = getString(cfg, "clientVersion") - } - } - } - - for _, cookie := range resp.Cookies() { - if cookie.Name == "sp_t" { - c.deviceID = cookie.Value - } - c.cookies[cookie.Name] = cookie.Value - } - - return nil -} - -func (c *SpotifyClient) getClientToken() error { - if c.clientID == "" || c.deviceID == "" || c.clientVersion == "" { - if err := c.getSessionInfo(); err != nil { - return err - } - if err := c.getAccessToken(); err != nil { - return err - } - } - - payload := map[string]interface{}{ - "client_data": map[string]interface{}{ - "client_version": c.clientVersion, - "client_id": c.clientID, - "js_sdk_data": map[string]interface{}{ - "device_brand": "unknown", - "device_model": "unknown", - "os": "windows", - "os_version": "NT 10.0", - "device_id": c.deviceID, - "device_type": "computer", - }, - }, - } - - jsonData, err := json.Marshal(payload) - if err != nil { - return err - } - - req, err := http.NewRequest("POST", "https://clienttoken.spotify.com/v1/clienttoken", bytes.NewBuffer(jsonData)) - if err != nil { - return err - } - - req.Header.Set("Authority", "clienttoken.spotify.com") - req.Header.Set("Content-Type", "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/145.0.0.0 Safari/537.36") - - resp, err := c.client.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - return fmt.Errorf("%w: client token request failed: HTTP %d", SpotifyError, resp.StatusCode) - } - - var data map[string]interface{} - if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { - return err - } - - if getString(data, "response_type") != "RESPONSE_GRANTED_TOKEN_RESPONSE" { - return fmt.Errorf("%w: invalid client token response type", SpotifyError) - } - - grantedToken := getMap(data, "granted_token") - c.clientToken = getString(grantedToken, "token") - - return nil -} - -func (c *SpotifyClient) Initialize() error { - if err := c.getSessionInfo(); err != nil { - return err - } - if err := c.getAccessToken(); err != nil { - return err - } - return c.getClientToken() -} - -func (c *SpotifyClient) Query(payload map[string]interface{}) (map[string]interface{}, error) { - if c.accessToken == "" || c.clientToken == "" { - if err := c.Initialize(); err != nil { - return nil, err - } - } - - jsonData, err := json.Marshal(payload) - if err != nil { - return nil, err - } - - req, err := http.NewRequest("POST", "https://api-partner.spotify.com/pathfinder/v2/query", bytes.NewBuffer(jsonData)) - if err != nil { - return nil, err - } - - req.Header.Set("Authorization", "Bearer "+c.accessToken) - req.Header.Set("Client-Token", c.clientToken) - req.Header.Set("Spotify-App-Version", c.clientVersion) - 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/145.0.0.0 Safari/537.36") - - resp, err := c.client.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - - if resp.StatusCode != 200 { - errorText := string(body) - if len(errorText) > 200 { - errorText = errorText[:200] - } - return nil, fmt.Errorf("%w: API query failed: HTTP %d | %s", SpotifyError, resp.StatusCode, errorText) - } - - var result map[string]interface{} - if err := json.Unmarshal(body, &result); err != nil { - return nil, err - } - - return result, nil -} - -func getString(m map[string]interface{}, key string) string { - if val, ok := m[key].(string); ok { - return val - } - return "" -} - -func getMap(m map[string]interface{}, key string) map[string]interface{} { - if val, ok := m[key].(map[string]interface{}); ok { - return val - } - return make(map[string]interface{}) -} - -func getSlice(m map[string]interface{}, key string) []interface{} { - if val, ok := m[key].([]interface{}); ok { - return val - } - return nil -} - -func getFloat64(m map[string]interface{}, key string) float64 { - if val, ok := m[key].(float64); ok { - return val - } - return 0 -} - -func getInt(m map[string]interface{}, key string) int { - if val, ok := m[key].(int); ok { - return val - } - if val, ok := m[key].(float64); ok { - return int(val) - } - return 0 -} - -func getBool(m map[string]interface{}, key string) bool { - if val, ok := m[key].(bool); ok { - return val - } - return false -} - -func extractArtists(artistsData map[string]interface{}) []map[string]interface{} { - items := getSlice(artistsData, "items") - - artists := []map[string]interface{}{} - for _, item := range items { - itemMap, ok := item.(map[string]interface{}) - if !ok { - continue - } - profile := getMap(itemMap, "profile") - artistInfo := map[string]interface{}{ - "name": getString(profile, "name"), - } - artists = append(artists, artistInfo) - } - return artists -} - -func extractCoverImage(coverData map[string]interface{}) map[string]interface{} { - if len(coverData) == 0 { - return nil - } - - var sources []interface{} - if srcs, ok := coverData["sources"].([]interface{}); ok { - sources = srcs - } else if squareImg, ok := coverData["squareCoverImage"].(map[string]interface{}); ok { - if img, ok := squareImg["image"].(map[string]interface{}); ok { - if data, ok := img["data"].(map[string]interface{}); ok { - if srcs, ok := data["sources"].([]interface{}); ok { - sources = srcs - } - } - } - } - - if len(sources) == 0 { - return nil - } - - type sourceInfo struct { - url string - width float64 - height float64 - } - - filteredSources := []sourceInfo{} - for _, s := range sources { - sMap, ok := s.(map[string]interface{}) - if !ok { - continue - } - url := getString(sMap, "url") - if url == "" { - continue - } - - width := getFloat64(sMap, "width") - if width == 0 { - width = getFloat64(sMap, "maxWidth") - } - height := getFloat64(sMap, "height") - if height == 0 { - height = getFloat64(sMap, "maxHeight") - } - - if (width > 64 && height > 64) || (width == 0 && height == 0 && url != "") { - filteredSources = append(filteredSources, sourceInfo{url: url, width: width, height: height}) - } - } - - if len(filteredSources) == 0 { - return nil - } - - sort.Slice(filteredSources, func(i, j int) bool { - return filteredSources[i].width < filteredSources[j].width - }) - - var smallURL, mediumURL, imageID, fallbackURL string - - for _, source := range filteredSources { - if source.width == 300 { - smallURL = source.url - } else if source.width == 640 { - mediumURL = source.url - } else if source.width == 0 { - fallbackURL = source.url - } - - if imageID == "" && source.url != "" { - if strings.Contains(source.url, "ab67616d0000b273") { - parts := strings.Split(source.url, "ab67616d0000b273") - if len(parts) > 1 { - imageID = parts[len(parts)-1] - } - } else if strings.Contains(source.url, "ab67616d00001e02") { - parts := strings.Split(source.url, "ab67616d00001e02") - if len(parts) > 1 { - imageID = parts[len(parts)-1] - } - } else if strings.Contains(source.url, "/image/") { - parts := strings.Split(source.url, "/image/") - if len(parts) > 1 { - imagePart := strings.Split(parts[len(parts)-1], "?")[0] - if len(imagePart) > 20 { - prefixes := []string{"ab67616d0000b273", "ab67616d00001e02", "ab67616d00004851"} - for _, prefix := range prefixes { - if strings.Contains(imagePart, prefix) { - subParts := strings.Split(imagePart, prefix) - if len(subParts) > 1 { - imageID = subParts[len(subParts)-1] - break - } - } - } - } - } - } - } - } - - largeURL := "" - if imageID != "" { - largeURL = "https://i.scdn.co/image/ab67616d000082c1" + imageID - } - - result := map[string]interface{}{} - if smallURL != "" { - result["small"] = smallURL - } - if mediumURL != "" { - result["medium"] = mediumURL - } - if largeURL != "" { - result["large"] = largeURL - } - - if len(result) == 0 && fallbackURL != "" { - result["small"] = fallbackURL - result["medium"] = fallbackURL - result["large"] = fallbackURL - } - - if len(result) == 0 { - return nil - } - return result -} - -func extractDuration(ms float64) map[string]interface{} { - totalSeconds := int(ms) / 1000 - minutes := totalSeconds / 60 - seconds := totalSeconds % 60 - return map[string]interface{}{ - "formatted": fmt.Sprintf("%d:%02d", minutes, seconds), - } -} - -func FilterTrack(data map[string]interface{}, separator string, albumFetchData ...map[string]interface{}) map[string]interface{} { - dataMap := getMap(data, "data") - trackData := getMap(dataMap, "trackUnion") - if len(trackData) == 0 { - return make(map[string]interface{}) - } - - var albumFetchDataMap map[string]interface{} - if len(albumFetchData) > 0 { - albumFetchDataMap = albumFetchData[0] - } - - artists := extractArtists(getMap(trackData, "artists")) - - if len(artists) == 0 { - artists = []map[string]interface{}{} - firstArtistItems := getSlice(getMap(trackData, "firstArtist"), "items") - for _, item := range firstArtistItems { - itemMap, ok := item.(map[string]interface{}) - if !ok { - continue - } - if profile, exists := itemMap["profile"]; exists { - profileMap, ok := profile.(map[string]interface{}) - if ok { - artistInfo := map[string]interface{}{ - "name": getString(profileMap, "name"), - } - artists = append(artists, artistInfo) - } - } - } - - otherArtistItems := getSlice(getMap(trackData, "otherArtists"), "items") - for _, item := range otherArtistItems { - itemMap, ok := item.(map[string]interface{}) - if !ok { - continue - } - if profile, exists := itemMap["profile"]; exists { - profileMap, ok := profile.(map[string]interface{}) - if ok { - artistInfo := map[string]interface{}{ - "name": getString(profileMap, "name"), - } - artists = append(artists, artistInfo) - } - } - } - } - - if len(artists) == 0 { - albumData := getMap(trackData, "albumOfTrack") - if len(albumData) > 0 { - artists = extractArtists(getMap(albumData, "artists")) - } - } - - albumData := getMap(trackData, "albumOfTrack") - var albumInfo map[string]interface{} - copyrightInfo := []map[string]interface{}{} - discInfo := map[string]interface{}{ - "discNumber": getFloat64(trackData, "discNumber"), - "totalDiscs": nil, - } - - if len(albumData) > 0 { - copyrightData := getMap(albumData, "copyright") - if len(copyrightData) > 0 { - copyrightItems := getSlice(copyrightData, "items") - if len(copyrightItems) > 0 { - for _, item := range copyrightItems { - itemMap, ok := item.(map[string]interface{}) - if !ok { - continue - } - if getString(itemMap, "type") != "P" { - copyrightInfo = append(copyrightInfo, map[string]interface{}{ - "text": getString(itemMap, "text"), - }) - } - } - } - } - - tracksData := getMap(albumData, "tracks") - if len(tracksData) > 0 { - discNumbers := make(map[int]bool) - trackItems := getSlice(tracksData, "items") - if len(trackItems) > 0 { - for _, item := range trackItems { - itemMap, ok := item.(map[string]interface{}) - if !ok { - continue - } - trackItem := getMap(itemMap, "track") - if len(trackItem) > 0 { - discNum := int(getFloat64(trackItem, "discNumber")) - if discNum == 0 { - discNum = 1 - } - discNumbers[discNum] = true - } - } - } - if len(discNumbers) > 0 { - maxDisc := 1 - for discNum := range discNumbers { - if discNum > maxDisc { - maxDisc = discNum - } - } - discInfo["totalDiscs"] = maxDisc - } - } - - dateInfo := getMap(albumData, "date") - releaseDate := getString(dateInfo, "isoString") - var releaseYear interface{} - if releaseDate == "" && len(dateInfo) > 0 { - yearStr := getString(dateInfo, "year") - monthStr := getString(dateInfo, "month") - dayStr := getString(dateInfo, "day") - if yearStr != "" { - year, err := strconv.Atoi(yearStr) - if err == nil { - releaseYear = year - if monthStr != "" && dayStr != "" { - month, _ := strconv.Atoi(monthStr) - day, _ := strconv.Atoi(dayStr) - releaseDate = fmt.Sprintf("%s-%02d-%02d", yearStr, month, day) - } else { - releaseDate = yearStr - } - } - } - } else if releaseDate != "" { - parts := strings.Split(releaseDate, "T") - if len(parts) > 0 { - releaseDate = parts[0] - } else { - parts = strings.Split(releaseDate, " ") - if len(parts) > 0 { - releaseDate = parts[0] - } - } - dateParts := strings.Split(releaseDate, "-") - if len(dateParts) > 0 && dateParts[0] != "" { - year, err := strconv.Atoi(dateParts[0]) - if err == nil { - releaseYear = year - } - } - } - - tracksTotalCount := float64(0) - if len(tracksData) > 0 { - tracksTotalCount = getFloat64(tracksData, "totalCount") - } - - albumID := getString(albumData, "id") - if albumID == "" { - albumURI := getString(albumData, "uri") - if strings.Contains(albumURI, ":") { - parts := strings.Split(albumURI, ":") - albumID = parts[len(parts)-1] - } - } - - albumArtistsString := "" - albumLabel := "" - if len(albumFetchDataMap) > 0 { - albumUnionData := getMap(getMap(albumFetchDataMap, "data"), "albumUnion") - if len(albumUnionData) > 0 { - albumArtists := extractArtists(getMap(albumUnionData, "artists")) - if len(albumArtists) > 0 { - albumArtistNames := []string{} - for _, artist := range albumArtists { - albumArtistNames = append(albumArtistNames, getString(artist, "name")) - } - albumArtistsString = strings.Join(albumArtistNames, separator) - } - if albumArtistsString == "" { - albumArtistsString = getString(albumUnionData, "artists") - } - albumLabel = getString(albumUnionData, "label") - } - } - - if albumArtistsString == "" { - albumArtists := extractArtists(getMap(albumData, "artists")) - if len(albumArtists) > 0 { - albumArtistNames := []string{} - for _, artist := range albumArtists { - albumArtistNames = append(albumArtistNames, getString(artist, "name")) - } - albumArtistsString = strings.Join(albumArtistNames, separator) - } - } - - albumInfo = map[string]interface{}{ - "id": albumID, - "name": getString(albumData, "name"), - "released": releaseDate, - "year": releaseYear, - "tracks": int(tracksTotalCount), - } - - if albumArtistsString != "" { - albumInfo["artists"] = albumArtistsString - } - - if albumLabel != "" { - albumInfo["label"] = albumLabel - } - } - - cover := extractCoverImage(getMap(trackData, "visualIdentity")) - if cover == nil && len(albumData) > 0 { - cover = extractCoverImage(getMap(albumData, "coverArt")) - } - - durationMs := getFloat64(getMap(trackData, "duration"), "totalMilliseconds") - durationObj := extractDuration(durationMs) - durationString := getString(durationObj, "formatted") - - artistNames := []string{} - for _, artist := range artists { - artistNames = append(artistNames, getString(artist, "name")) - } - artistsString := strings.Join(artistNames, separator) - - copyrightTexts := []string{} - for _, item := range copyrightInfo { - copyrightTexts = append(copyrightTexts, getString(item, "text")) - } - copyrightString := strings.Join(copyrightTexts, GetSeparator()) - - discNumber := int(getFloat64(trackData, "discNumber")) - if discNumber == 0 { - discNumber = 1 - } - - maxDiscFromAlbum := 0 - totalDiscsFromAlbum := 0 - - if len(albumFetchData) > 0 && albumFetchData[0] != nil { - albumUnion := getMap(getMap(albumFetchData[0], "data"), "albumUnion") - if len(albumUnion) > 0 { - discsData := getMap(albumUnion, "discs") - if len(discsData) > 0 { - totalDiscsFromAlbum = int(getFloat64(discsData, "totalCount")) - } - - albumTracks := getMap(albumUnion, "tracks") - if len(albumTracks) > 0 { - albumTrackItems := getSlice(albumTracks, "items") - currentTrackID := getString(trackData, "id") - for idx, item := range albumTrackItems { - itemMap, ok := item.(map[string]interface{}) - if !ok { - continue - } - trackItem := getMap(itemMap, "track") - if len(trackItem) > 0 { - dNum := int(getFloat64(trackItem, "discNumber")) - if dNum > maxDiscFromAlbum { - maxDiscFromAlbum = dNum - } - - trackURI := getString(trackItem, "uri") - if strings.Contains(trackURI, currentTrackID) || getString(trackItem, "id") == currentTrackID { - if dNum > 0 { - discNumber = dNum - } - } - - trackNum := int(getFloat64(trackData, "trackNumber")) - itemTrackNum := idx + 1 - if trackNum == itemTrackNum && dNum > 0 { - } - } - } - } - } - } - - totalDiscs := 1 - if totalDiscsFromAlbum > 0 { - totalDiscs = totalDiscsFromAlbum - } else if maxDiscFromAlbum > 0 { - totalDiscs = maxDiscFromAlbum - } else if discInfo["totalDiscs"] != nil { - totalDiscs = discInfo["totalDiscs"].(int) - } - - contentRating := getMap(trackData, "contentRating") - isExplicit := getString(contentRating, "label") == "EXPLICIT" - - filtered := map[string]interface{}{ - "id": getString(trackData, "id"), - "name": getString(trackData, "name"), - "artists": artistsString, - "album": albumInfo, - "duration": durationString, - "track": int(getFloat64(trackData, "trackNumber")), - "disc": discNumber, - "discs": totalDiscs, - "copyright": copyrightString, - "plays": getString(trackData, "playcount"), - "cover": cover, - "is_explicit": isExplicit, - } - - return filtered -} - -func FilterAlbum(data map[string]interface{}, separator string) map[string]interface{} { - dataMap := getMap(data, "data") - albumData := getMap(dataMap, "albumUnion") - if len(albumData) == 0 { - return make(map[string]interface{}) - } - - artists := extractArtists(getMap(albumData, "artists")) - artistNames := []string{} - for _, artist := range artists { - artistNames = append(artistNames, getString(artist, "name")) - } - albumArtistsString := strings.Join(artistNames, separator) - - coverObj := extractCoverImage(getMap(albumData, "coverArt")) - var cover interface{} - if coverObj != nil { - - cover = getString(coverObj, "small") - if cover == "" { - cover = getString(coverObj, "medium") - } - if cover == "" { - cover = getString(coverObj, "large") - } - } - - tracks := []map[string]interface{}{} - tracksData := getMap(albumData, "tracksV2") - trackItems := getSlice(tracksData, "items") - if trackItems != nil { - for _, item := range trackItems { - itemMap, ok := item.(map[string]interface{}) - if !ok { - continue - } - track := getMap(itemMap, "track") - if len(track) == 0 { - continue - } - - artistsData := getMap(track, "artists") - trackArtists := extractArtists(artistsData) - trackDurationMs := getFloat64(getMap(track, "duration"), "totalMilliseconds") - durationObj := extractDuration(trackDurationMs) - durationString := getString(durationObj, "formatted") - - trackArtistNames := []string{} - artistIDs := []string{} - - artistItems := getSlice(artistsData, "items") - if artistItems != nil { - for _, artistItem := range artistItems { - artistItemMap, ok := artistItem.(map[string]interface{}) - if !ok { - continue - } - artistURI := getString(artistItemMap, "uri") - if artistURI != "" && strings.Contains(artistURI, ":") { - parts := strings.Split(artistURI, ":") - if len(parts) > 0 { - artistID := parts[len(parts)-1] - if artistID != "" { - artistIDs = append(artistIDs, artistID) - } - } - } - } - } - - for _, artist := range trackArtists { - trackArtistNames = append(trackArtistNames, getString(artist, "name")) - } - trackArtistsString := strings.Join(trackArtistNames, separator) - - trackURI := getString(track, "uri") - trackID := "" - if strings.Contains(trackURI, ":") { - parts := strings.Split(trackURI, ":") - trackID = parts[len(parts)-1] - } - - contentRating := getMap(track, "contentRating") - isExplicit := getString(contentRating, "label") == "EXPLICIT" - - discNumber := int(getFloat64(track, "discNumber")) - if discNumber == 0 { - discNumber = 1 - } - - trackInfo := map[string]interface{}{ - "id": trackID, - "name": getString(track, "name"), - "artists": trackArtistsString, - "artistIds": artistIDs, - "duration": durationString, - "plays": getString(track, "playcount"), - "is_explicit": isExplicit, - "disc_number": discNumber, - } - tracks = append(tracks, trackInfo) - } - } - - dateInfo := getMap(albumData, "date") - releaseDate := getString(dateInfo, "isoString") - if releaseDate != "" && strings.Contains(releaseDate, "T") { - parts := strings.Split(releaseDate, "T") - releaseDate = parts[0] - } - - albumURI := getString(albumData, "uri") - albumID := "" - if strings.Contains(albumURI, ":") { - parts := strings.Split(albumURI, ":") - albumID = parts[len(parts)-1] - } - - totalDiscs := 1 - discsData := getMap(albumData, "discs") - if len(discsData) > 0 { - totalDiscs = int(getFloat64(discsData, "totalCount")) - } - - filtered := map[string]interface{}{ - "id": albumID, - "name": getString(albumData, "name"), - "artists": albumArtistsString, - "cover": cover, - "releaseDate": releaseDate, - "count": len(tracks), - "tracks": tracks, - "discs": map[string]interface{}{ - "totalCount": totalDiscs, - }, - "label": getString(albumData, "label"), - } - - return filtered -} - -func FilterPlaylist(data map[string]interface{}, separator string) map[string]interface{} { - dataMap := getMap(data, "data") - playlistData := getMap(dataMap, "playlistV2") - if len(playlistData) == 0 { - return make(map[string]interface{}) - } - - ownerData := getMap(getMap(playlistData, "ownerV2"), "data") - var ownerInfo map[string]interface{} - if len(ownerData) > 0 { - var avatarURL interface{} - avatarData := getMap(ownerData, "avatar") - if len(avatarData) > 0 { - sources := getSlice(avatarData, "sources") - if len(sources) > 0 { - if firstSource, ok := sources[0].(map[string]interface{}); ok { - avatarURL = getString(firstSource, "url") - } - } - } - - ownerInfo = map[string]interface{}{ - "name": getString(ownerData, "name"), - "avatar": avatarURL, - } - } - - imagesData := getMap(playlistData, "images") - if len(imagesData) == 0 { - imagesData = getMap(playlistData, "imagesV2") - } - var cover interface{} - if len(imagesData) > 0 { - imageItems := getSlice(imagesData, "items") - if imageItems != nil && len(imageItems) > 0 { - if firstImage, ok := imageItems[0].(map[string]interface{}); ok { - firstSources := getSlice(firstImage, "sources") - if firstSources != nil && len(firstSources) > 0 { - if firstSource, ok := firstSources[0].(map[string]interface{}); ok { - sourceURL := getString(firstSource, "url") - if sourceURL != "" { - cover = sourceURL - } - } - } - } - } - if cover == nil { - imageSources := getSlice(imagesData, "sources") - if imageSources != nil && len(imageSources) > 0 { - if firstSource, ok := imageSources[0].(map[string]interface{}); ok { - sourceURL := getString(firstSource, "url") - if sourceURL != "" { - cover = sourceURL - } - } - } - } - } - - tracks := []map[string]interface{}{} - content := getMap(playlistData, "content") - contentItems := getSlice(content, "items") - if contentItems != nil { - for _, item := range contentItems { - itemMap, ok := item.(map[string]interface{}) - if !ok { - continue - } - trackData := getMap(getMap(itemMap, "itemV2"), "data") - if len(trackData) == 0 { - continue - } - - var rank interface{} - var status interface{} - attributes := getSlice(itemMap, "attributes") - if attributes != nil { - for _, attr := range attributes { - attrMap, ok := attr.(map[string]interface{}) - if !ok { - continue - } - key := getString(attrMap, "key") - if key == "rank" { - rank = getString(attrMap, "value") - } else if key == "status" { - status = getString(attrMap, "value") - } - } - } - - artistsData := getMap(trackData, "artists") - trackArtists := extractArtists(artistsData) - trackArtistNames := []string{} - artistIDs := []string{} - - artistItems := getSlice(artistsData, "items") - if artistItems != nil { - for _, artistItem := range artistItems { - artistItemMap, ok := artistItem.(map[string]interface{}) - if !ok { - continue - } - artistURI := getString(artistItemMap, "uri") - if artistURI != "" && strings.Contains(artistURI, ":") { - parts := strings.Split(artistURI, ":") - if len(parts) > 0 { - artistID := parts[len(parts)-1] - if artistID != "" { - artistIDs = append(artistIDs, artistID) - } - } - } - } - } - - for _, artist := range trackArtists { - trackArtistNames = append(trackArtistNames, getString(artist, "name")) - } - artistsString := strings.Join(trackArtistNames, separator) - - trackDurationMs := getFloat64(getMap(trackData, "trackDuration"), "totalMilliseconds") - durationObj := extractDuration(trackDurationMs) - durationString := getString(durationObj, "formatted") - - trackURI := getString(trackData, "uri") - trackID := getString(trackData, "id") - if trackID == "" { - if strings.Contains(trackURI, ":") { - parts := strings.Split(trackURI, ":") - trackID = parts[len(parts)-1] - } - } - - albumData := getMap(trackData, "albumOfTrack") - albumName := "" - albumID := "" - albumArtistsString := "" - var trackCover interface{} - - if len(albumData) > 0 { - albumName = getString(albumData, "name") - albumURI := getString(albumData, "uri") - if strings.Contains(albumURI, ":") { - parts := strings.Split(albumURI, ":") - albumID = parts[len(parts)-1] - } - coverObj := extractCoverImage(getMap(albumData, "coverArt")) - if coverObj != nil { - - trackCover = getString(coverObj, "small") - if trackCover == "" { - trackCover = getString(coverObj, "medium") - } - if trackCover == "" { - trackCover = getString(coverObj, "large") - } - } - - albumArtists := extractArtists(getMap(albumData, "artists")) - if len(albumArtists) > 0 { - albumArtistNames := []string{} - for _, artist := range albumArtists { - albumArtistNames = append(albumArtistNames, getString(artist, "name")) - } - albumArtistsString = strings.Join(albumArtistNames, separator) - } - } - - contentRating := getMap(trackData, "contentRating") - isExplicit := getString(contentRating, "label") == "EXPLICIT" - - trackName := getString(trackData, "name") - if trackName == "" { - continue - } - - trackInfo := map[string]interface{}{ - "id": trackID, - "cover": trackCover, - "title": trackName, - "artist": artistsString, - "artistIds": artistIDs, - "plays": rank, - "status": status, - "album": albumName, - "albumArtist": albumArtistsString, - "albumId": albumID, - "duration": durationString, - "is_explicit": isExplicit, - "disc_number": int(getFloat64(trackData, "discNumber")), - } - tracks = append(tracks, trackInfo) - } - } - - followersData, exists := playlistData["followers"] - var followersCount interface{} - if exists { - if followersMap, ok := followersData.(map[string]interface{}); ok { - followersCount = getFloat64(followersMap, "totalCount") - } else if count, ok := followersData.(float64); ok { - followersCount = count - } else if count, ok := followersData.(int); ok { - followersCount = float64(count) - } else { - followersCount = float64(0) - } - } else { - followersCount = float64(0) - } - - playlistURI := getString(playlistData, "uri") - playlistID := "" - if strings.Contains(playlistURI, ":") { - parts := strings.Split(playlistURI, ":") - playlistID = parts[len(parts)-1] - } - - totalCount := getFloat64(content, "totalCount") - count := len(tracks) - if totalCount > 0 { - count = int(totalCount) - } - - filtered := map[string]interface{}{ - "id": playlistID, - "name": getString(playlistData, "name"), - "description": getString(playlistData, "description"), - "owner": ownerInfo, - "cover": cover, - "count": count, - "tracks": tracks, - "followers": followersCount, - } - - return filtered -} - -func extractRelease(release map[string]interface{}) map[string]interface{} { - if len(release) == 0 { - return nil - } - - dateInfo := getMap(release, "date") - releaseDate := getString(dateInfo, "isoString") - if releaseDate == "" && len(dateInfo) > 0 { - yearStr := getString(dateInfo, "year") - monthStr := getString(dateInfo, "month") - dayStr := getString(dateInfo, "day") - if yearStr != "" { - if monthStr != "" && dayStr != "" { - month, _ := strconv.Atoi(monthStr) - day, _ := strconv.Atoi(dayStr) - releaseDate = fmt.Sprintf("%s-%02d-%02d", yearStr, month, day) - } else { - releaseDate = yearStr - } - } - } else if releaseDate != "" && strings.Contains(releaseDate, "T") { - parts := strings.Split(releaseDate, "T") - releaseDate = parts[0] - } - - coverObj := extractCoverImage(getMap(release, "coverArt")) - var cover interface{} - if coverObj != nil { - cover = getString(coverObj, "medium") - } - - releaseID := getString(release, "id") - if releaseID == "" { - releaseURI := getString(release, "uri") - if strings.Contains(releaseURI, ":") { - parts := strings.Split(releaseURI, ":") - releaseID = parts[len(parts)-1] - } - } - - var year interface{} - if yearVal, exists := dateInfo["year"]; exists { - year = yearVal - } - - var totalTracks int - tracksInfo := getMap(release, "tracks") - if tracksInfo != nil { - totalTracks = int(getFloat64(tracksInfo, "totalCount")) - } - - return map[string]interface{}{ - "id": releaseID, - "name": getString(release, "name"), - "cover": cover, - "date": releaseDate, - "year": year, - "total_tracks": totalTracks, - "type": getString(release, "type"), - } -} - -func extractDiscographyItems(itemsData map[string]interface{}) []map[string]interface{} { - items := []map[string]interface{}{} - dataItems := getSlice(itemsData, "items") - if dataItems != nil { - for _, item := range dataItems { - itemMap, ok := item.(map[string]interface{}) - if !ok { - continue - } - releases := getMap(itemMap, "releases") - var release map[string]interface{} - if len(releases) > 0 { - releaseItems := getSlice(releases, "items") - if releaseItems != nil && len(releaseItems) > 0 { - if releaseMap, ok := releaseItems[0].(map[string]interface{}); ok { - release = releaseMap - } - } - } else { - release = getMap(itemMap, "album") - } - - if len(release) > 0 { - extracted := extractRelease(release) - if extracted != nil { - items = append(items, extracted) - } - } - } - } - return items -} - -func stripHTMLTags(s string) string { - re := regexp.MustCompile(`(?s)<[^>]*>`) - return re.ReplaceAllString(s, "") -} - -func FilterArtist(data map[string]interface{}, separator string) map[string]interface{} { - dataMap := getMap(data, "data") - artistData := getMap(dataMap, "artistUnion") - if len(artistData) == 0 { - return make(map[string]interface{}) - } - - profileRaw := getMap(artistData, "profile") - profile := make(map[string]interface{}) - if len(profileRaw) > 0 { - if biography, exists := profileRaw["biography"]; exists { - biographyMap, ok := biography.(map[string]interface{}) - if ok { - biographyText := getString(biographyMap, "text") - if biographyText != "" { - profile["biography"] = html.UnescapeString(stripHTMLTags(biographyText)) - } - } - } - if _, exists := profileRaw["name"]; exists { - profile["name"] = getString(profileRaw, "name") - } - if _, exists := profileRaw["verified"]; exists { - profile["verified"] = getBool(profileRaw, "verified") - } - } - - headerImageData := getMap(artistData, "headerImage") - var headerImage interface{} - if len(headerImageData) > 0 { - headerData := getMap(headerImageData, "data") - if len(headerData) > 0 { - sources := getSlice(headerData, "sources") - if sources != nil && len(sources) > 0 { - if firstSource, ok := sources[0].(map[string]interface{}); ok { - headerImage = getString(firstSource, "url") - } - } - } - } - - statsRaw := getMap(artistData, "stats") - stats := make(map[string]interface{}) - if len(statsRaw) > 0 { - if _, exists := statsRaw["followers"]; exists { - stats["followers"] = getFloat64(statsRaw, "followers") - } - if _, exists := statsRaw["monthlyListeners"]; exists { - stats["listeners"] = getFloat64(statsRaw, "monthlyListeners") - } - if _, exists := statsRaw["worldRank"]; exists { - stats["rank"] = getFloat64(statsRaw, "worldRank") - } - } - - discography := getMap(artistData, "discography") - discographyResult := make(map[string]interface{}) - - allData := getMap(discography, "all") - if len(allData) > 0 { - discographyResult["all"] = extractDiscographyItems(allData) - if totalCount, exists := allData["totalCount"]; exists { - var total float64 - if tc, ok := totalCount.(float64); ok { - total = tc - } else if tc, ok := totalCount.(int); ok { - total = float64(tc) - } else if tc, ok := totalCount.(int64); ok { - total = float64(tc) - } - discographyResult["total"] = total - } - } - - visualsData := getMap(artistData, "visuals") - galleryData := getMap(visualsData, "gallery") - gallery := []interface{}{} - if len(galleryData) > 0 { - galleryItems := getSlice(galleryData, "items") - if galleryItems != nil { - for _, item := range galleryItems { - itemMap, ok := item.(map[string]interface{}) - if !ok { - continue - } - sources := getSlice(itemMap, "sources") - if sources != nil && len(sources) > 0 { - if firstSource, ok := sources[0].(map[string]interface{}); ok { - galleryURL := getString(firstSource, "url") - if galleryURL != "" { - gallery = append(gallery, galleryURL) - } - } - } - } - } - } - - avatarObj := extractCoverImage(getMap(visualsData, "avatarImage")) - var avatar interface{} - if avatarObj != nil { - if mediumURL, ok := avatarObj["medium"].(string); ok && mediumURL != "" { - avatar = mediumURL - } else if smallURL, ok := avatarObj["small"].(string); ok && smallURL != "" { - avatar = smallURL - } - } - - artistURI := getString(artistData, "uri") - artistID := "" - if strings.Contains(artistURI, ":") { - parts := strings.Split(artistURI, ":") - artistID = parts[len(parts)-1] - } - - filtered := map[string]interface{}{ - "id": artistID, - "name": getString(profile, "name"), - "profile": profile, - "avatar": avatar, - "header": headerImage, - "stats": stats, - "gallery": gallery, - "discography": discographyResult, - } - - return filtered -} - -func FilterSearch(data map[string]interface{}, separator string) map[string]interface{} { - dataMap := getMap(data, "data") - searchData := getMap(dataMap, "searchV2") - if len(searchData) == 0 { - return make(map[string]interface{}) - } - - results := map[string]interface{}{ - "tracks": []map[string]interface{}{}, - "albums": []map[string]interface{}{}, - "artists": []map[string]interface{}{}, - "playlists": []map[string]interface{}{}, - } - - tracksData := getMap(searchData, "tracksV2") - if len(tracksData) == 0 { - tracksData = getMap(searchData, "tracks") - } - trackItems := getSlice(tracksData, "items") - if trackItems != nil { - for _, item := range trackItems { - itemMap, ok := item.(map[string]interface{}) - if !ok { - continue - } - var track map[string]interface{} - if itemData, exists := itemMap["item"]; exists { - itemDataMap, ok := itemData.(map[string]interface{}) - if ok { - track = getMap(itemDataMap, "data") - } - } else if trackData, exists := itemMap["track"]; exists { - if trackMap, ok := trackData.(map[string]interface{}); ok { - track = trackMap - } - } - - if len(track) == 0 { - continue - } - - trackArtists := extractArtists(getMap(track, "artists")) - trackDurationMs := getFloat64(getMap(track, "duration"), "totalMilliseconds") - if trackDurationMs == 0 { - trackDurationMs = getFloat64(getMap(track, "trackDuration"), "totalMilliseconds") - } - trackDuration := extractDuration(trackDurationMs) - - albumData := getMap(track, "albumOfTrack") - var albumInfo map[string]interface{} - if len(albumData) > 0 { - albumURI := getString(albumData, "uri") - albumID := getString(albumData, "id") - if albumID == "" { - if strings.Contains(albumURI, ":") { - parts := strings.Split(albumURI, ":") - albumID = parts[len(parts)-1] - } - } - albumInfo = map[string]interface{}{ - "name": getString(albumData, "name"), - "uri": albumURI, - "id": albumID, - } - } - - trackURI := getString(track, "uri") - trackID := getString(track, "id") - if trackID == "" { - if strings.Contains(trackURI, ":") { - parts := strings.Split(trackURI, ":") - trackID = parts[len(parts)-1] - } - } - - coverObj := extractCoverImage(getMap(albumData, "coverArt")) - var cover interface{} - if coverObj != nil { - cover = getString(coverObj, "medium") - } - - trackName := getString(track, "name") - if trackName == "" { - continue - } - - trackArtistNames := []string{} - for _, artist := range trackArtists { - trackArtistNames = append(trackArtistNames, getString(artist, "name")) - } - trackArtistsString := strings.Join(trackArtistNames, separator) - - durationString := getString(trackDuration, "formatted") - - albumName := "" - if albumInfo != nil { - albumName = getString(albumInfo, "name") - } - - contentRating := getMap(track, "contentRating") - isExplicit := getString(contentRating, "label") == "EXPLICIT" - - trackResults := results["tracks"].([]map[string]interface{}) - trackResults = append(trackResults, map[string]interface{}{ - "id": trackID, - "name": trackName, - "artists": trackArtistsString, - "album": albumName, - "duration": durationString, - "cover": cover, - "is_explicit": isExplicit, - }) - results["tracks"] = trackResults - } - } - - albumsData := getMap(searchData, "albumsV2") - if len(albumsData) == 0 { - albumsData = getMap(searchData, "albums") - } - albumItems := getSlice(albumsData, "items") - if albumItems != nil { - for _, item := range albumItems { - itemMap, ok := item.(map[string]interface{}) - if !ok { - continue - } - var album map[string]interface{} - if itemData, exists := itemMap["data"]; exists { - if albumMap, ok := itemData.(map[string]interface{}); ok { - album = albumMap - } - } else if albumData, exists := itemMap["album"]; exists { - if albumMap, ok := albumData.(map[string]interface{}); ok { - album = albumMap - } - } - - if len(album) == 0 { - continue - } - - albumArtists := extractArtists(getMap(album, "artists")) - albumURI := getString(album, "uri") - albumID := getString(album, "id") - if albumID == "" { - if strings.Contains(albumURI, ":") { - parts := strings.Split(albumURI, ":") - albumID = parts[len(parts)-1] - } - } - - coverObj := extractCoverImage(getMap(album, "coverArt")) - var cover interface{} - if coverObj != nil { - cover = getString(coverObj, "medium") - } - - albumArtistNames := []string{} - for _, artist := range albumArtists { - albumArtistNames = append(albumArtistNames, getString(artist, "name")) - } - albumArtistsString := strings.Join(albumArtistNames, separator) - - dateInfo := getMap(album, "date") - var year interface{} - if len(dateInfo) > 0 { - if yearVal, exists := dateInfo["year"]; exists { - year = yearVal - } - } - - albumName := getString(album, "name") - if albumName == "" || albumArtistsString == "" { - continue - } - - albumResult := map[string]interface{}{ - "id": albumID, - "name": albumName, - "artists": albumArtistsString, - "cover": cover, - } - - if year != nil { - albumResult["year"] = year - } - - albumResults := results["albums"].([]map[string]interface{}) - albumResults = append(albumResults, albumResult) - results["albums"] = albumResults - } - } - - artistsData := getMap(searchData, "artistsV2") - if len(artistsData) == 0 { - artistsData = getMap(searchData, "artists") - } - artistItems := getSlice(artistsData, "items") - if artistItems != nil { - for _, item := range artistItems { - itemMap, ok := item.(map[string]interface{}) - if !ok { - continue - } - var artist map[string]interface{} - if itemData, exists := itemMap["data"]; exists { - if artistMap, ok := itemData.(map[string]interface{}); ok { - artist = artistMap - } - } else if artistData, exists := itemMap["artist"]; exists { - if artistMap, ok := artistData.(map[string]interface{}); ok { - artist = artistMap - } - } - - if len(artist) == 0 { - continue - } - - artistURI := getString(artist, "uri") - artistID := "" - if strings.Contains(artistURI, ":") { - parts := strings.Split(artistURI, ":") - artistID = parts[len(parts)-1] - } - - coverObj := extractCoverImage(getMap(artist, "visualIdentity")) - if coverObj == nil { - visuals := getMap(artist, "visuals") - if len(visuals) > 0 { - coverObj = extractCoverImage(getMap(visuals, "avatarImage")) - } - } - - var cover interface{} - if coverObj != nil { - cover = getString(coverObj, "medium") - } - - artistName := getString(getMap(artist, "profile"), "name") - if artistName == "" { - artistName = getString(artist, "name") - } - - if artistName == "" { - continue - } - - artistResults := results["artists"].([]map[string]interface{}) - artistResults = append(artistResults, map[string]interface{}{ - "id": artistID, - "name": artistName, - "cover": cover, - }) - results["artists"] = artistResults - } - } - - playlistsData := getMap(searchData, "playlistsV2") - if len(playlistsData) == 0 { - playlistsData = getMap(searchData, "playlists") - } - playlistItems := getSlice(playlistsData, "items") - if playlistItems != nil { - for _, item := range playlistItems { - itemMap, ok := item.(map[string]interface{}) - if !ok { - continue - } - var playlist map[string]interface{} - if itemData, exists := itemMap["data"]; exists { - if playlistMap, ok := itemData.(map[string]interface{}); ok { - playlist = playlistMap - } - } else if playlistData, exists := itemMap["playlist"]; exists { - if playlistMap, ok := playlistData.(map[string]interface{}); ok { - playlist = playlistMap - } - } - - if len(playlist) == 0 { - continue - } - - playlistURI := getString(playlist, "uri") - playlistID := "" - if strings.Contains(playlistURI, ":") { - parts := strings.Split(playlistURI, ":") - playlistID = parts[len(parts)-1] - } - - playlistImages := getMap(playlist, "images") - if len(playlistImages) == 0 { - playlistImages = getMap(playlist, "imagesV2") - } - var playlistCoverObj map[string]interface{} - if len(playlistImages) > 0 { - imageItems := getSlice(playlistImages, "items") - if imageItems != nil && len(imageItems) > 0 { - if firstImage, ok := imageItems[0].(map[string]interface{}); ok { - firstSources := getSlice(firstImage, "sources") - if firstSources != nil { - playlistCoverObj = extractCoverImage(map[string]interface{}{"sources": firstSources}) - } - } - } - if playlistCoverObj == nil { - playlistCoverObj = extractCoverImage(playlistImages) - } - } - - var playlistCover interface{} - if playlistCoverObj != nil { - playlistCover = getString(playlistCoverObj, "medium") - } - - ownerData := getMap(getMap(playlist, "ownerV2"), "data") - ownerName := getString(ownerData, "name") - - playlistName := getString(playlist, "name") - if playlistName == "" { - continue - } - - playlistResult := map[string]interface{}{ - "id": playlistID, - "name": playlistName, - "cover": playlistCover, - } - - if ownerName != "" { - playlistResult["owner"] = ownerName - } - - playlistResults := results["playlists"].([]map[string]interface{}) - playlistResults = append(playlistResults, playlistResult) - results["playlists"] = playlistResults - } - } - - tracks := results["tracks"].([]map[string]interface{}) - albums := results["albums"].([]map[string]interface{}) - artists := results["artists"].([]map[string]interface{}) - playlists := results["playlists"].([]map[string]interface{}) - - return map[string]interface{}{ - "results": results, - "totalResults": map[string]interface{}{ - "tracks": len(tracks), - "albums": len(albums), - "artists": len(artists), - "playlists": len(playlists), - }, - } -} diff --git a/backend/spotify_metadata.go b/backend/spotify_metadata.go deleted file mode 100644 index 35129ef..0000000 --- a/backend/spotify_metadata.go +++ /dev/null @@ -1,1774 +0,0 @@ -package backend - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "net/url" - "regexp" - "strconv" - "strings" - "time" -) - -var ( - errInvalidSpotifyURL = errors.New("invalid or unsupported Spotify URL") -) - -type MetadataCallback func(data interface{}) - -type SpotifyMetadataClient struct { - httpClient *http.Client - Separator string -} - -func NewSpotifyMetadataClient() *SpotifyMetadataClient { - return &SpotifyMetadataClient{ - httpClient: &http.Client{Timeout: 30 * time.Second}, - Separator: ", ", - } -} - -type TrackMetadata struct { - SpotifyID string `json:"spotify_id,omitempty"` - Artists string `json:"artists"` - Name string `json:"name"` - AlbumName string `json:"album_name"` - AlbumArtist string `json:"album_artist,omitempty"` - DurationMS int `json:"duration_ms"` - Images string `json:"images"` - ReleaseDate string `json:"release_date"` - TrackNumber int `json:"track_number"` - TotalTracks int `json:"total_tracks,omitempty"` - DiscNumber int `json:"disc_number,omitempty"` - TotalDiscs int `json:"total_discs,omitempty"` - ExternalURL string `json:"external_urls"` - AlbumID string `json:"album_id,omitempty"` - AlbumURL string `json:"album_url,omitempty"` - ArtistID string `json:"artist_id,omitempty"` - ArtistURL string `json:"artist_url,omitempty"` - ArtistsData []ArtistSimple `json:"artists_data,omitempty"` - UPC string `json:"upc,omitempty"` - Copyright string `json:"copyright,omitempty"` - Publisher string `json:"publisher,omitempty"` - Composer string `json:"composer,omitempty"` - Plays string `json:"plays,omitempty"` - PreviewURL string `json:"preview_url,omitempty"` - IsExplicit bool `json:"is_explicit,omitempty"` -} - -type ArtistSimple struct { - ID string `json:"id"` - Name string `json:"name"` - ExternalURL string `json:"external_urls"` -} - -type AlbumTrackMetadata struct { - SpotifyID string `json:"spotify_id,omitempty"` - Artists string `json:"artists"` - Name string `json:"name"` - AlbumName string `json:"album_name"` - AlbumArtist string `json:"album_artist,omitempty"` - DurationMS int `json:"duration_ms"` - Images string `json:"images"` - ReleaseDate string `json:"release_date"` - TrackNumber int `json:"track_number"` - TotalTracks int `json:"total_tracks,omitempty"` - DiscNumber int `json:"disc_number,omitempty"` - TotalDiscs int `json:"total_discs,omitempty"` - ExternalURL string `json:"external_urls"` - AlbumType string `json:"album_type,omitempty"` - AlbumID string `json:"album_id,omitempty"` - AlbumURL string `json:"album_url,omitempty"` - ArtistID string `json:"artist_id,omitempty"` - ArtistURL string `json:"artist_url,omitempty"` - ArtistsData []ArtistSimple `json:"artists_data,omitempty"` - UPC string `json:"upc,omitempty"` - Plays string `json:"plays,omitempty"` - Status string `json:"status,omitempty"` - PreviewURL string `json:"preview_url,omitempty"` - IsExplicit bool `json:"is_explicit,omitempty"` -} - -type TrackResponse struct { - Track TrackMetadata `json:"track"` -} - -type AlbumInfoMetadata struct { - TotalTracks int `json:"total_tracks"` - Name string `json:"name"` - ReleaseDate string `json:"release_date"` - Artists string `json:"artists"` - Images string `json:"images"` - UPC string `json:"upc,omitempty"` - Batch string `json:"batch,omitempty"` - ArtistID string `json:"artist_id,omitempty"` - ArtistURL string `json:"artist_url,omitempty"` -} - -type AlbumResponsePayload struct { - AlbumInfo AlbumInfoMetadata `json:"album_info"` - TrackList []AlbumTrackMetadata `json:"track_list"` -} - -type PlaylistInfoMetadata struct { - Tracks struct { - Total int `json:"total"` - } `json:"tracks"` - Followers struct { - Total int `json:"total"` - } `json:"followers"` - Owner struct { - DisplayName string `json:"display_name"` - Name string `json:"name"` - Images string `json:"images"` - } `json:"owner"` - Cover string `json:"cover,omitempty"` - Description string `json:"description,omitempty"` - Batch string `json:"batch,omitempty"` -} - -type PlaylistResponsePayload struct { - PlaylistInfo PlaylistInfoMetadata `json:"playlist_info"` - TrackList []AlbumTrackMetadata `json:"track_list"` -} - -type ArtistInfoMetadata struct { - Name string `json:"name"` - Followers int `json:"followers"` - Genres []string `json:"genres"` - Images string `json:"images"` - Header string `json:"header,omitempty"` - Gallery []string `json:"gallery,omitempty"` - ExternalURL string `json:"external_urls"` - DiscographyType string `json:"discography_type"` - TotalAlbums int `json:"total_albums"` - Biography string `json:"biography,omitempty"` - Verified bool `json:"verified,omitempty"` - Listeners int `json:"listeners,omitempty"` - Rank int `json:"rank,omitempty"` - Batch string `json:"batch,omitempty"` -} - -type DiscographyAlbumMetadata struct { - ID string `json:"id"` - Name string `json:"name"` - AlbumType string `json:"album_type"` - ReleaseDate string `json:"release_date"` - TotalTracks int `json:"total_tracks"` - Artists string `json:"artists"` - Images string `json:"images"` - ExternalURL string `json:"external_urls"` -} - -type ArtistDiscographyPayload struct { - ArtistInfo ArtistInfoMetadata `json:"artist_info"` - AlbumList []DiscographyAlbumMetadata `json:"album_list"` - TrackList []AlbumTrackMetadata `json:"track_list"` -} - -type ArtistResponsePayload struct { - Artist struct { - Name string `json:"name"` - Followers int `json:"followers"` - Genres []string `json:"genres"` - Images string `json:"images"` - ExternalURL string `json:"external_urls"` - Popularity int `json:"popularity"` - } `json:"artist"` -} - -type spotifyURI struct { - Type string - ID string - DiscographyGroup string -} - -type apiTrackResponse struct { - ID string `json:"id"` - Name string `json:"name"` - Artists string `json:"artists"` - ArtistIds []string `json:"artistIds,omitempty"` - UPC string `json:"upc,omitempty"` - Duration string `json:"duration"` - Track int `json:"track"` - Disc int `json:"disc"` - Discs int `json:"discs"` - Copyright string `json:"copyright"` - Composer string `json:"composer,omitempty"` - Plays string `json:"plays"` - Album struct { - ID string `json:"id"` - Name string `json:"name"` - Released string `json:"released"` - Year int `json:"year"` - Tracks int `json:"tracks"` - Artists string `json:"artists"` - Label string `json:"label"` - } `json:"album"` - Cover struct { - Small string `json:"small"` - Medium string `json:"medium"` - Large string `json:"large"` - } `json:"cover"` - IsExplicit bool `json:"is_explicit"` -} - -type apiAlbumResponse struct { - ID string `json:"id"` - Name string `json:"name"` - Artists string `json:"artists"` - Cover string `json:"cover"` - ReleaseDate string `json:"releaseDate"` - UPC string `json:"upc,omitempty"` - Count int `json:"count"` - Label string `json:"label"` - Discs struct { - TotalCount int `json:"totalCount"` - } `json:"discs"` - Tracks []struct { - ID string `json:"id"` - Name string `json:"name"` - Artists string `json:"artists"` - ArtistIds []string `json:"artistIds"` - Duration string `json:"duration"` - Plays string `json:"plays"` - UPC string `json:"upc,omitempty"` - IsExplicit bool `json:"is_explicit"` - DiscNumber int `json:"disc_number"` - } `json:"tracks"` -} - -type apiPlaylistResponse struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - Owner struct { - Name string `json:"name"` - Avatar string `json:"avatar"` - } `json:"owner"` - Cover string `json:"cover"` - Count int `json:"count"` - Followers int `json:"followers"` - Tracks []struct { - ID string `json:"id"` - Cover string `json:"cover"` - Title string `json:"title"` - Artist string `json:"artist"` - ArtistIds []string `json:"artistIds"` - Plays string `json:"plays"` - Status string `json:"status"` - Album string `json:"album"` - AlbumArtist string `json:"albumArtist"` - AlbumID string `json:"albumId"` - UPC string `json:"upc,omitempty"` - Duration string `json:"duration"` - IsExplicit bool `json:"is_explicit"` - DiscNumber int `json:"disc_number"` - } `json:"tracks"` -} - -type apiArtistResponse struct { - ID string `json:"id"` - Name string `json:"name"` - Profile struct { - Biography string `json:"biography"` - Name string `json:"name"` - Verified bool `json:"verified"` - } `json:"profile"` - Avatar string `json:"avatar"` - Header string `json:"header"` - Stats struct { - Followers int `json:"followers"` - Listeners int `json:"listeners"` - Rank int `json:"rank"` - } `json:"stats"` - Gallery []string `json:"gallery"` - Discography struct { - All []struct { - ID string `json:"id"` - Name string `json:"name"` - Cover string `json:"cover"` - Date string `json:"date"` - Year int `json:"year"` - TotalTracks int `json:"total_tracks"` - Type string `json:"type"` - } `json:"all"` - Total int `json:"total"` - } `json:"discography"` -} - -type apiSearchResponse struct { - Results struct { - Tracks []struct { - ID string `json:"id"` - Name string `json:"name"` - Artists string `json:"artists"` - Album string `json:"album"` - Duration string `json:"duration"` - Cover string `json:"cover"` - IsExplicit bool `json:"is_explicit"` - } `json:"tracks"` - Albums []struct { - ID string `json:"id"` - Name string `json:"name"` - Artists string `json:"artists"` - Cover string `json:"cover"` - Year int `json:"year"` - } `json:"albums"` - Artists []struct { - ID string `json:"id"` - Name string `json:"name"` - Cover string `json:"cover"` - } `json:"artists"` - Playlists []struct { - ID string `json:"id"` - Name string `json:"name"` - Cover string `json:"cover"` - Owner string `json:"owner"` - } `json:"playlists"` - } `json:"results"` - TotalResults struct { - Tracks int `json:"tracks"` - Albums int `json:"albums"` - Artists int `json:"artists"` - Playlists int `json:"playlists"` - } `json:"totalResults"` -} - -type SearchResult struct { - ID string `json:"id"` - Name string `json:"name"` - Type string `json:"type"` - Artists string `json:"artists,omitempty"` - AlbumName string `json:"album_name,omitempty"` - Images string `json:"images"` - ReleaseDate string `json:"release_date,omitempty"` - ExternalURL string `json:"external_urls"` - Duration int `json:"duration_ms,omitempty"` - TotalTracks int `json:"total_tracks,omitempty"` - Owner string `json:"owner,omitempty"` - IsExplicit bool `json:"is_explicit,omitempty"` -} - -type SearchResponse struct { - Tracks []SearchResult `json:"tracks"` - Albums []SearchResult `json:"albums"` - Artists []SearchResult `json:"artists"` - Playlists []SearchResult `json:"playlists"` -} - -func GetFilteredSpotifyData(ctx context.Context, spotifyURL string, batch bool, delay time.Duration, separator string, callback MetadataCallback) (interface{}, error) { - client := NewSpotifyMetadataClient() - 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, callback MetadataCallback) (interface{}, error) { - parsed, err := parseSpotifyURI(spotifyURL) - if err != nil { - return nil, err - } - - raw, err := c.getRawSpotifyData(ctx, parsed, batch, delay, callback) - if err != nil { - return nil, err - } - - return c.processSpotifyData(ctx, raw, callback) -} - -func (c *SpotifyMetadataClient) getRawSpotifyData(ctx context.Context, parsed spotifyURI, batch bool, delay time.Duration, callback MetadataCallback) (interface{}, error) { - switch parsed.Type { - case "playlist": - return c.fetchPlaylist(ctx, parsed.ID, callback) - case "album": - return c.fetchAlbum(ctx, parsed.ID, callback) - case "track": - return c.fetchTrack(ctx, parsed.ID) - case "artist_discography": - return c.fetchArtistDiscography(ctx, parsed, callback) - case "artist": - - discographyParsed := spotifyURI{Type: "artist_discography", ID: parsed.ID, DiscographyGroup: "all"} - return c.fetchArtistDiscography(ctx, discographyParsed, callback) - default: - return nil, fmt.Errorf("unsupported Spotify type: %s", parsed.Type) - } -} - -func (c *SpotifyMetadataClient) processSpotifyData(ctx context.Context, raw interface{}, callback MetadataCallback) (interface{}, error) { - switch payload := raw.(type) { - case *apiPlaylistResponse: - return c.formatPlaylistData(payload, callback), nil - case *apiAlbumResponse: - return c.formatAlbumData(payload, callback) - case *apiTrackResponse: - return c.formatTrackData(payload), nil - case *apiArtistResponse: - return c.formatArtistDiscographyData(ctx, payload, callback) - default: - return nil, errors.New("unknown raw payload type") - } -} - -func (c *SpotifyMetadataClient) fetchTrack(ctx context.Context, trackID string) (*apiTrackResponse, error) { - client := NewSpotifyClient() - if err := client.Initialize(); err != nil { - return nil, fmt.Errorf("failed to initialize spotify client: %w", err) - } - - payload := map[string]interface{}{ - "variables": map[string]interface{}{ - "uri": fmt.Sprintf("spotify:track:%s", trackID), - }, - "operationName": "getTrack", - "extensions": map[string]interface{}{ - "persistedQuery": map[string]interface{}{ - "version": 1, - "sha256Hash": "612585ae06ba435ad26369870deaae23b5c8800a256cd8a57e08eddc25a37294", - }, - }, - } - - data, err := client.Query(payload) - if err != nil { - return nil, fmt.Errorf("failed to query track: %w", err) - } - - var albumFetchData map[string]interface{} - if trackData, ok := data["data"].(map[string]interface{}); ok { - if trackUnion, ok := trackData["trackUnion"].(map[string]interface{}); ok { - if albumOfTrack, ok := trackUnion["albumOfTrack"].(map[string]interface{}); ok { - albumID := "" - if id, ok := albumOfTrack["id"].(string); ok && id != "" { - albumID = id - } else if uri, ok := albumOfTrack["uri"].(string); ok && uri != "" { - if strings.Contains(uri, ":") { - parts := strings.Split(uri, ":") - if len(parts) > 0 { - albumID = parts[len(parts)-1] - } - } - } - - if albumID != "" { - - albumResponse, err := c.fetchAlbumWithClient(ctx, client, albumID, nil) - if err == nil && albumResponse != nil { - - albumJSON, _ := json.Marshal(albumResponse) - var albumMap map[string]interface{} - json.Unmarshal(albumJSON, &albumMap) - - tracksItems := []interface{}{} - if albumMap["tracks"] != nil { - if trackList, ok := albumMap["tracks"].([]interface{}); ok { - for _, t := range trackList { - if trackMap, ok := t.(map[string]interface{}); ok { - tracksItems = append(tracksItems, map[string]interface{}{ - "track": map[string]interface{}{ - "discNumber": trackMap["disc_number"], - "id": trackMap["id"], - "uri": fmt.Sprintf("spotify:track:%s", trackMap["id"]), - }, - }) - } - } - } - } - - albumFetchData = map[string]interface{}{ - "data": map[string]interface{}{ - "albumUnion": map[string]interface{}{ - "discs": map[string]interface{}{ - "totalCount": albumResponse.Discs.TotalCount, - }, - "tracks": map[string]interface{}{ - "items": tracksItems, - "totalCount": albumResponse.Count, - }, - "artists": albumResponse.Artists, - "label": albumResponse.Label, - }, - }, - } - } - } - } - } - } - - filteredData := FilterTrack(data, c.Separator, albumFetchData) - composer, composerErr := c.fetchTrackComposerWithClient(ctx, client, trackID) - if composerErr == nil && composer != "" { - filteredData["composer"] = composer - } - - jsonData, err := json.Marshal(filteredData) - if err != nil { - return nil, fmt.Errorf("failed to marshal filtered data: %w", err) - } - - var result apiTrackResponse - if err := json.Unmarshal(jsonData, &result); err != nil { - return nil, fmt.Errorf("failed to unmarshal to apiTrackResponse: %w", err) - } - - if result.ID != "" { - if identifiers, err := GetSpotifyTrackIdentifiersDirect(result.ID); err == nil || identifiers.UPC != "" { - if identifiers.UPC != "" { - result.UPC = identifiers.UPC - } - } - } - - return &result, nil -} - -func collectTrackCreditNamesByRole(items []interface{}, role string) []string { - role = strings.TrimSpace(role) - if role == "" || len(items) == 0 { - return nil - } - - seen := make(map[string]struct{}, len(items)) - names := make([]string, 0, len(items)) - for _, item := range items { - itemMap, ok := item.(map[string]interface{}) - if !ok { - continue - } - - if !strings.EqualFold(strings.TrimSpace(getString(itemMap, "role")), role) { - continue - } - - name := strings.TrimSpace(getString(itemMap, "name")) - if name == "" { - continue - } - if _, exists := seen[name]; exists { - continue - } - - seen[name] = struct{}{} - names = append(names, name) - } - - return names -} - -func (c *SpotifyMetadataClient) fetchTrackComposerWithClient(ctx context.Context, client *SpotifyClient, trackID string) (string, error) { - _ = ctx - - payload := map[string]interface{}{ - "variables": map[string]interface{}{ - "trackUri": fmt.Sprintf("spotify:track:%s", trackID), - "contributorsLimit": 100, - "contributorsOffset": 0, - }, - "operationName": "queryTrackCreditsModal", - "extensions": map[string]interface{}{ - "persistedQuery": map[string]interface{}{ - "version": 1, - "sha256Hash": "e2ca40d46cf1fde36562261ccec754f23fb31b561877252e9fe0d6834aabb84b", - }, - }, - } - - data, err := client.Query(payload) - if err != nil { - return "", fmt.Errorf("failed to query track credits: %w", err) - } - - creditItems := getSlice( - getMap( - getMap( - getMap( - getMap(data, "data"), - "trackUnion", - ), - "creditsTrait", - ), - "contributors", - ), - "items", - ) - - composerNames := collectTrackCreditNamesByRole(creditItems, "Composer") - if len(composerNames) == 0 { - return "", nil - } - - separator := strings.TrimSpace(c.Separator) - if separator == "" { - separator = ", " - } - - return strings.Join(composerNames, separator), nil -} - -func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID string, callback MetadataCallback) (*apiAlbumResponse, error) { - client := NewSpotifyClient() - if err := client.Initialize(); err != nil { - return nil, fmt.Errorf("failed to initialize spotify client: %w", err) - } - return c.fetchAlbumWithClient(ctx, client, albumID, callback) -} - -func (c *SpotifyMetadataClient) fetchAlbumWithClient(ctx context.Context, client *SpotifyClient, albumID string, callback MetadataCallback) (*apiAlbumResponse, error) { - - allItems := []interface{}{} - offset := 0 - limit := 1000 - var totalCount interface{} - var data map[string]interface{} - - for { - payload := map[string]interface{}{ - "variables": map[string]interface{}{ - "uri": fmt.Sprintf("spotify:album:%s", albumID), - "locale": "", - "offset": offset, - "limit": limit, - }, - "operationName": "getAlbum", - "extensions": map[string]interface{}{ - "persistedQuery": map[string]interface{}{ - "version": 1, - "sha256Hash": "b9bfabef66ed756e5e13f68a942deb60bd4125ec1f1be8cc42769dc0259b4b10", - }, - }, - } - - response, err := client.Query(payload) - if err != nil { - return nil, fmt.Errorf("failed to query album: %w", err) - } - - if data == nil { - 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") - tracksData := getMap(albumData, "tracksV2") - items := getSlice(tracksData, "items") - - if items == nil || len(items) == 0 { - break - } - - allItems = append(allItems, items...) - - if totalCount == nil { - if tc, ok := tracksData["totalCount"].(float64); ok { - totalCount = int(tc) - } else { - totalCount = len(items) - } - } - - tcInt := 0 - if tc, ok := totalCount.(int); ok { - tcInt = tc - } else if tc, ok := totalCount.(float64); ok { - tcInt = int(tc) - } - - if len(allItems) >= tcInt || len(items) < limit { - break - } - - offset += limit - } - - if data != nil && len(allItems) > 0 { - dataMap := getMap(data, "data") - albumUnion := getMap(dataMap, "albumUnion") - tracksV2 := getMap(albumUnion, "tracksV2") - tracksV2["items"] = allItems - tracksV2["totalCount"] = len(allItems) - } - - filteredData := FilterAlbum(data, c.Separator) - - jsonData, err := json.Marshal(filteredData) - if err != nil { - return nil, fmt.Errorf("failed to marshal filtered data: %w", err) - } - - var result apiAlbumResponse - if err := json.Unmarshal(jsonData, &result); err != nil { - return nil, fmt.Errorf("failed to unmarshal to apiAlbumResponse: %w", err) - } - - if result.ID != "" { - if upc, err := lookupSpotifyAlbumUPC(result.ID); err == nil && strings.TrimSpace(upc) != "" { - result.UPC = upc - for i := range result.Tracks { - if strings.TrimSpace(result.Tracks[i].UPC) == "" { - result.Tracks[i].UPC = upc - } - } - } - } - - return &result, nil -} - -func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID string, callback MetadataCallback) (*apiPlaylistResponse, error) { - client := NewSpotifyClient() - if err := client.Initialize(); err != nil { - return nil, fmt.Errorf("failed to initialize spotify client: %w", err) - } - - allItems := []interface{}{} - offset := 0 - limit := 1000 - var totalCount interface{} - var data map[string]interface{} - - for { - payload := map[string]interface{}{ - "variables": map[string]interface{}{ - "uri": fmt.Sprintf("spotify:playlist:%s", playlistID), - "offset": offset, - "limit": limit, - "enableWatchFeedEntrypoint": false, - }, - "operationName": "fetchPlaylist", - "extensions": map[string]interface{}{ - "persistedQuery": map[string]interface{}{ - "version": 1, - "sha256Hash": "bb67e0af06e8d6f52b531f97468ee4acd44cd0f82b988e15c2ea47b1148efc77", - }, - }, - } - - response, err := client.Query(payload) - if err != nil { - return nil, fmt.Errorf("failed to query playlist: %w", err) - } - - if data == nil { - 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") - content := getMap(playlistData, "content") - items := getSlice(content, "items") - - if items == nil || len(items) == 0 { - break - } - - allItems = append(allItems, items...) - - if totalCount == nil { - if tc, ok := content["totalCount"].(float64); ok { - totalCount = int(tc) - } else { - totalCount = len(items) - } - } - - tcInt := 0 - if tc, ok := totalCount.(int); ok { - tcInt = tc - } else if tc, ok := totalCount.(float64); ok { - tcInt = int(tc) - } - - if len(allItems) >= tcInt || len(items) < limit { - break - } - - offset += limit - } - - if data != nil && len(allItems) > 0 { - dataMap := getMap(data, "data") - playlistV2 := getMap(dataMap, "playlistV2") - content := getMap(playlistV2, "content") - content["items"] = allItems - content["totalCount"] = len(allItems) - } - - filteredData := FilterPlaylist(data, c.Separator) - - jsonData, err := json.Marshal(filteredData) - if err != nil { - return nil, fmt.Errorf("failed to marshal filtered data: %w", err) - } - - var result apiPlaylistResponse - if err := json.Unmarshal(jsonData, &result); err != nil { - return nil, fmt.Errorf("failed to unmarshal to apiPlaylistResponse: %w", err) - } - - return &result, nil -} - -func (c *SpotifyMetadataClient) fetchArtistDiscography(ctx context.Context, parsed spotifyURI, callback MetadataCallback) (*apiArtistResponse, error) { - client := NewSpotifyClient() - if err := client.Initialize(); err != nil { - return nil, fmt.Errorf("failed to initialize spotify client: %w", err) - } - - overviewPayload := map[string]interface{}{ - "variables": map[string]interface{}{ - "uri": fmt.Sprintf("spotify:artist:%s", parsed.ID), - "locale": "", - }, - "operationName": "queryArtistOverview", - "extensions": map[string]interface{}{ - "persistedQuery": map[string]interface{}{ - "version": 1, - "sha256Hash": "446130b4a0aa6522a686aafccddb0ae849165b5e0436fd802f96e0243617b5d8", - }, - }, - } - - data, err := client.Query(overviewPayload) - if err != nil { - 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{}{} - offset := 0 - limit := 50 - var totalCount interface{} - - for { - discographyPayload := map[string]interface{}{ - "variables": map[string]interface{}{ - "uri": fmt.Sprintf("spotify:artist:%s", parsed.ID), - "offset": offset, - "limit": limit, - "order": "DATE_DESC", - }, - "operationName": "queryArtistDiscographyAll", - "extensions": map[string]interface{}{ - "persistedQuery": map[string]interface{}{ - "version": 1, - "sha256Hash": "5e07d323febb57b4a56a42abbf781490e58764aa45feb6e3dc0591564fc56599", - }, - }, - } - - response, err := client.Query(discographyPayload) - if err != nil { - break - } - - discographyData := getMap(getMap(getMap(response, "data"), "artistUnion"), "discography") - allData := getMap(discographyData, "all") - items := getSlice(allData, "items") - - if items == nil || len(items) == 0 { - break - } - - allDiscographyItems = append(allDiscographyItems, items...) - - if totalCount == nil { - if tc, ok := allData["totalCount"].(float64); ok { - totalCount = int(tc) - } else { - totalCount = len(items) - } - } - - tcInt := 0 - if tc, ok := totalCount.(int); ok { - tcInt = tc - } else if tc, ok := totalCount.(float64); ok { - tcInt = int(tc) - } - - if len(allDiscographyItems) >= tcInt || len(items) < limit { - break - } - - offset += limit - - select { - case <-ctx.Done(): - return nil, ctx.Err() - default: - } - } - - albumsItems := []interface{}{} - compilationsItems := []interface{}{} - singlesItems := []interface{}{} - - for _, item := range allDiscographyItems { - itemMap, ok := item.(map[string]interface{}) - if !ok { - continue - } - - releases := getMap(itemMap, "releases") - releaseItems := getSlice(releases, "items") - var release map[string]interface{} - if len(releaseItems) > 0 { - if r, ok := releaseItems[0].(map[string]interface{}); ok { - release = r - } - } - - if release != nil { - releaseType := getString(release, "type") - switch releaseType { - case "ALBUM": - albumsItems = append(albumsItems, item) - case "COMPILATION": - compilationsItems = append(compilationsItems, item) - case "SINGLE": - singlesItems = append(singlesItems, item) - default: - singlesItems = append(singlesItems, item) - } - } - } - - if len(allDiscographyItems) > 0 { - dataMap := getMap(data, "data") - artistUnion := getMap(dataMap, "artistUnion") - discographyMap := getMap(artistUnion, "discography") - - if len(albumsItems) > 0 { - discographyMap["albums"] = map[string]interface{}{ - "items": albumsItems, - "totalCount": len(albumsItems), - } - } - if len(compilationsItems) > 0 { - discographyMap["compilations"] = map[string]interface{}{ - "items": compilationsItems, - "totalCount": len(compilationsItems), - } - } - if len(singlesItems) > 0 { - discographyMap["singles"] = map[string]interface{}{ - "items": singlesItems, - "totalCount": len(singlesItems), - } - } - - discographyMap["all"] = map[string]interface{}{ - "items": allDiscographyItems, - "totalCount": len(allDiscographyItems), - } - } - - filteredData := FilterArtist(data, c.Separator) - - jsonData, err := json.Marshal(filteredData) - if err != nil { - return nil, fmt.Errorf("failed to marshal filtered data: %w", err) - } - - var result apiArtistResponse - if err := json.Unmarshal(jsonData, &result); err != nil { - return nil, fmt.Errorf("failed to unmarshal to apiArtistResponse: %w", err) - } - - return &result, nil -} - -func (c *SpotifyMetadataClient) formatTrackData(raw *apiTrackResponse) TrackResponse { - durationMS := parseDuration(raw.Duration) - - externalURL := fmt.Sprintf("https://open.spotify.com/track/%s", raw.ID) - albumID := strings.TrimSpace(raw.Album.ID) - albumURL := "" - if albumID != "" { - albumURL = fmt.Sprintf("https://open.spotify.com/album/%s", albumID) - } - artistID := "" - artistURL := "" - artistsData := make([]ArtistSimple, 0, len(raw.ArtistIds)) - for index, id := range raw.ArtistIds { - trimmedID := strings.TrimSpace(id) - if trimmedID == "" { - continue - } - if artistID == "" { - artistID = trimmedID - artistURL = fmt.Sprintf("https://open.spotify.com/artist/%s", trimmedID) - } - artistName := "" - artistNames := splitAndCleanArtists(raw.Artists) - if index < len(artistNames) { - artistName = artistNames[index] - } - artistsData = append(artistsData, ArtistSimple{ - ID: trimmedID, - Name: artistName, - ExternalURL: fmt.Sprintf("https://open.spotify.com/artist/%s", trimmedID), - }) - } - - coverURL := raw.Cover.Small - if coverURL == "" { - coverURL = raw.Cover.Medium - } - if coverURL == "" { - coverURL = raw.Cover.Large - } - - releaseDate := raw.Album.Released - if releaseDate == "" && raw.Album.Year > 0 { - releaseDate = fmt.Sprintf("%d", raw.Album.Year) - } - trackMetadata := TrackMetadata{ - SpotifyID: raw.ID, - Artists: raw.Artists, - Name: raw.Name, - AlbumName: raw.Album.Name, - AlbumArtist: raw.Album.Artists, - DurationMS: durationMS, - Images: coverURL, - ReleaseDate: releaseDate, - TrackNumber: raw.Track, - TotalTracks: raw.Album.Tracks, - DiscNumber: raw.Disc, - TotalDiscs: raw.Discs, - ExternalURL: externalURL, - AlbumID: albumID, - AlbumURL: albumURL, - ArtistID: artistID, - ArtistURL: artistURL, - ArtistsData: artistsData, - UPC: raw.UPC, - Copyright: raw.Copyright, - Publisher: raw.Album.Label, - Composer: raw.Composer, - Plays: raw.Plays, - IsExplicit: raw.IsExplicit, - } - - return TrackResponse{ - Track: trackMetadata, - } -} - -func (c *SpotifyMetadataClient) formatAlbumData(raw *apiAlbumResponse, callback MetadataCallback) (*AlbumResponsePayload, error) { - var artistID, artistURL string - for _, item := range raw.Tracks { - if len(item.ArtistIds) == 0 { - continue - } - candidate := strings.TrimSpace(item.ArtistIds[0]) - if candidate == "" { - continue - } - artistID = candidate - artistURL = fmt.Sprintf("https://open.spotify.com/artist/%s", candidate) - break - } - - info := AlbumInfoMetadata{ - TotalTracks: raw.Count, - Name: raw.Name, - ReleaseDate: raw.ReleaseDate, - Artists: raw.Artists, - Images: raw.Cover, - UPC: raw.UPC, - ArtistID: artistID, - ArtistURL: artistURL, - } - - if callback != nil { - callback(AlbumResponsePayload{ - AlbumInfo: info, - TrackList: []AlbumTrackMetadata{}, - }) - } - - tracks := make([]AlbumTrackMetadata, 0, len(raw.Tracks)) - for idx, item := range raw.Tracks { - durationMS := parseDuration(item.Duration) - trackNumber := idx + 1 - trackUPC := strings.TrimSpace(item.UPC) - if trackUPC == "" { - trackUPC = strings.TrimSpace(raw.UPC) - } - - var artistID, artistURL string - if len(item.ArtistIds) > 0 { - artistID = item.ArtistIds[0] - artistURL = fmt.Sprintf("https://open.spotify.com/artist/%s", artistID) - } - - artistsData := make([]ArtistSimple, 0, len(item.ArtistIds)) - for _, id := range item.ArtistIds { - artistsData = append(artistsData, ArtistSimple{ - ID: id, - Name: "", - ExternalURL: fmt.Sprintf("https://open.spotify.com/artist/%s", id), - }) - } - - tracks = append(tracks, AlbumTrackMetadata{ - SpotifyID: item.ID, - Artists: item.Artists, - Name: item.Name, - AlbumName: raw.Name, - AlbumArtist: raw.Artists, - DurationMS: durationMS, - Images: raw.Cover, - ReleaseDate: raw.ReleaseDate, - TrackNumber: trackNumber, - TotalTracks: raw.Count, - DiscNumber: item.DiscNumber, - TotalDiscs: raw.Discs.TotalCount, - ExternalURL: fmt.Sprintf("https://open.spotify.com/track/%s", item.ID), - AlbumID: raw.ID, - AlbumURL: fmt.Sprintf("https://open.spotify.com/album/%s", raw.ID), - ArtistID: artistID, - ArtistURL: artistURL, - ArtistsData: artistsData, - UPC: trackUPC, - Plays: item.Plays, - IsExplicit: item.IsExplicit, - }) - } - - if callback != nil { - callback(tracks) - } - - return &AlbumResponsePayload{ - AlbumInfo: info, - TrackList: tracks, - }, nil -} - -func (c *SpotifyMetadataClient) formatPlaylistData(raw *apiPlaylistResponse, callback MetadataCallback) PlaylistResponsePayload { - var info PlaylistInfoMetadata - info.Tracks.Total = raw.Count - info.Followers.Total = raw.Followers - info.Owner.DisplayName = raw.Owner.Name - info.Owner.Name = raw.Name - info.Owner.Images = raw.Owner.Avatar - info.Cover = raw.Cover - info.Description = raw.Description - - if callback != nil { - callback(PlaylistResponsePayload{ - PlaylistInfo: info, - TrackList: []AlbumTrackMetadata{}, - }) - } - - tracks := make([]AlbumTrackMetadata, 0, len(raw.Tracks)) - for _, item := range raw.Tracks { - durationMS := parseDuration(item.Duration) - - var artistID, artistURL string - if len(item.ArtistIds) > 0 { - artistID = item.ArtistIds[0] - artistURL = fmt.Sprintf("https://open.spotify.com/artist/%s", artistID) - } - - artistsData := make([]ArtistSimple, 0, len(item.ArtistIds)) - for _, id := range item.ArtistIds { - artistsData = append(artistsData, ArtistSimple{ - ID: id, - Name: "", - ExternalURL: fmt.Sprintf("https://open.spotify.com/artist/%s", id), - }) - } - - tracks = append(tracks, AlbumTrackMetadata{ - SpotifyID: item.ID, - Artists: item.Artist, - Name: item.Title, - AlbumName: item.Album, - AlbumArtist: item.AlbumArtist, - DurationMS: durationMS, - Images: item.Cover, - ReleaseDate: "", - TrackNumber: 0, - TotalTracks: 0, - DiscNumber: item.DiscNumber, - TotalDiscs: 0, - ExternalURL: fmt.Sprintf("https://open.spotify.com/track/%s", item.ID), - AlbumID: item.AlbumID, - AlbumURL: fmt.Sprintf("https://open.spotify.com/album/%s", item.AlbumID), - ArtistID: artistID, - ArtistURL: artistURL, - ArtistsData: artistsData, - UPC: item.UPC, - Plays: item.Plays, - Status: item.Status, - IsExplicit: item.IsExplicit, - }) - } - - if callback != nil { - callback(tracks) - } - - return PlaylistResponsePayload{ - PlaylistInfo: info, - TrackList: tracks, - } -} - -func (c *SpotifyMetadataClient) formatArtistDiscographyData(ctx context.Context, raw *apiArtistResponse, callback MetadataCallback) (*ArtistDiscographyPayload, error) { - discType := "all" - - info := ArtistInfoMetadata{ - Name: raw.Name, - Followers: raw.Stats.Followers, - Genres: []string{}, - Images: raw.Avatar, - Header: raw.Header, - Gallery: raw.Gallery, - ExternalURL: fmt.Sprintf("https://open.spotify.com/artist/%s", raw.ID), - DiscographyType: discType, - TotalAlbums: raw.Discography.Total, - Biography: raw.Profile.Biography, - Verified: raw.Profile.Verified, - Listeners: raw.Stats.Listeners, - Rank: raw.Stats.Rank, - } - - albumList := make([]DiscographyAlbumMetadata, 0, len(raw.Discography.All)) - allTracks := make([]AlbumTrackMetadata, 0) - - type fetchResult struct { - tracks []AlbumTrackMetadata - err error - } - - resultsChan := make(chan fetchResult, len(raw.Discography.All)) - sem := make(chan struct{}, 5) - - sharedClient := NewSpotifyClient() - if err := sharedClient.Initialize(); err != nil { - return nil, fmt.Errorf("failed to initialize shared spotify client: %w", err) - } - - for _, alb := range raw.Discography.All { - albumList = append(albumList, DiscographyAlbumMetadata{ - ID: alb.ID, - Name: alb.Name, - AlbumType: alb.Type, - ReleaseDate: alb.Date, - TotalTracks: alb.TotalTracks, - Artists: raw.Name, - Images: alb.Cover, - 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) { - sem <- struct{}{} - - time.Sleep(100 * time.Millisecond) - defer func() { <-sem }() - - select { - case <-ctx.Done(): - resultsChan <- fetchResult{err: ctx.Err()} - return - default: - } - - albumData, err := c.fetchAlbumWithClient(ctx, sharedClient, albumID, nil) - if err != nil { - fmt.Printf("Error getting tracks for album %s: %v\n", albumName, err) - resultsChan <- fetchResult{tracks: []AlbumTrackMetadata{}} - return - } - - tracks := make([]AlbumTrackMetadata, 0, len(albumData.Tracks)) - for idx, tr := range albumData.Tracks { - durationMS := parseDuration(tr.Duration) - trackNumber := idx + 1 - - var artistID, artistURL string - if len(tr.ArtistIds) > 0 { - artistID = tr.ArtistIds[0] - artistURL = fmt.Sprintf("https://open.spotify.com/artist/%s", artistID) - } - - artistsData := make([]ArtistSimple, 0, len(tr.ArtistIds)) - for _, id := range tr.ArtistIds { - artistsData = append(artistsData, ArtistSimple{ - ID: id, - Name: "", - ExternalURL: fmt.Sprintf("https://open.spotify.com/artist/%s", id), - }) - } - - tracks = append(tracks, AlbumTrackMetadata{ - SpotifyID: tr.ID, - Artists: tr.Artists, - Name: tr.Name, - AlbumName: albumData.Name, - AlbumArtist: raw.Name, - AlbumType: "album", - DurationMS: durationMS, - Images: albumData.Cover, - ReleaseDate: albumData.ReleaseDate, - TrackNumber: trackNumber, - TotalTracks: albumData.Count, - DiscNumber: tr.DiscNumber, - UPC: tr.UPC, - ExternalURL: fmt.Sprintf("https://open.spotify.com/track/%s", tr.ID), - AlbumID: albumID, - AlbumURL: fmt.Sprintf("https://open.spotify.com/album/%s", albumID), - ArtistID: artistID, - ArtistURL: artistURL, - ArtistsData: artistsData, - Plays: tr.Plays, - IsExplicit: tr.IsExplicit, - }) - } - if callback != nil { - callback(tracks) - } - resultsChan <- fetchResult{tracks: tracks} - }(alb.ID, alb.Name) - } - - for i := 0; i < len(raw.Discography.All); i++ { - res := <-resultsChan - if res.err != nil { - return nil, res.err - } - allTracks = append(allTracks, res.tracks...) - } - - return &ArtistDiscographyPayload{ - ArtistInfo: info, - AlbumList: albumList, - TrackList: allTracks, - }, nil -} - -func parseDuration(durationStr string) int { - if durationStr == "" { - return 0 - } - - parts := strings.Split(durationStr, ":") - if len(parts) != 2 { - return 0 - } - - minutes, err1 := strconv.Atoi(parts[0]) - seconds, err2 := strconv.Atoi(parts[1]) - if err1 != nil || err2 != nil { - return 0 - } - - return (minutes*60 + seconds) * 1000 -} - -func parseSpotifyURI(input string) (spotifyURI, error) { - trimmed := strings.TrimSpace(input) - if trimmed == "" { - return spotifyURI{}, errInvalidSpotifyURL - } - - if strings.HasPrefix(trimmed, "spotify:") { - parts := strings.Split(trimmed, ":") - if len(parts) == 3 { - switch parts[1] { - case "album", "track", "playlist", "artist": - return spotifyURI{Type: parts[1], ID: parts[2]}, nil - } - } - } - - parsed, err := url.Parse(trimmed) - if err != nil { - return spotifyURI{}, err - } - - if parsed.Host != "open.spotify.com" && parsed.Host != "play.spotify.com" { - return spotifyURI{}, errInvalidSpotifyURL - } - - parts := cleanPathParts(parsed.Path) - if len(parts) == 0 { - return spotifyURI{}, errInvalidSpotifyURL - } - - if parts[0] == "embed" { - parts = parts[1:] - } - if len(parts) == 0 { - return spotifyURI{}, errInvalidSpotifyURL - } - if strings.HasPrefix(parts[0], "intl-") { - parts = parts[1:] - } - if len(parts) == 0 { - return spotifyURI{}, errInvalidSpotifyURL - } - - if len(parts) == 2 { - switch parts[0] { - case "album", "track", "playlist", "artist": - return spotifyURI{Type: parts[0], ID: parts[1]}, nil - } - } - - if len(parts) >= 3 && parts[0] == "artist" { - if len(parts) >= 3 && parts[2] == "discography" { - discType := "all" - if len(parts) >= 4 { - candidate := parts[3] - if candidate == "all" || candidate == "album" || candidate == "single" || candidate == "compilation" { - discType = candidate - } - } - return spotifyURI{Type: "artist_discography", ID: parts[1], DiscographyGroup: discType}, nil - } - return spotifyURI{Type: "artist", ID: parts[1]}, nil - } - - return spotifyURI{}, errInvalidSpotifyURL -} - -func cleanPathParts(path string) []string { - raw := strings.Split(path, "/") - parts := make([]string, 0, len(raw)) - for _, part := range raw { - if part != "" { - parts = append(parts, part) - } - } - return parts -} - -func parseArtistIDsFromString(artists string) []string { - return []string{} -} - -func splitAndCleanArtists(artists string) []string { - raw := regexp.MustCompile(`\s*[;,]\s*`).Split(strings.TrimSpace(artists), -1) - parts := make([]string, 0, len(raw)) - for _, part := range raw { - part = strings.TrimSpace(part) - if part != "" { - parts = append(parts, part) - } - } - return parts -} - -func (c *SpotifyMetadataClient) Search(ctx context.Context, query string, limit int) (*SearchResponse, error) { - if query == "" { - return nil, errors.New("search query cannot be empty") - } - - if limit <= 0 || limit > 50 { - limit = 50 - } - - client := NewSpotifyClient() - if err := client.Initialize(); err != nil { - return nil, fmt.Errorf("failed to initialize spotify client: %w", err) - } - - payload := map[string]interface{}{ - "variables": map[string]interface{}{ - "searchTerm": query, - "offset": 0, - "limit": limit, - "numberOfTopResults": 5, - "includeAudiobooks": true, - "includeArtistHasConcertsField": false, - "includePreReleases": true, - "includeAuthors": false, - }, - "operationName": "searchDesktop", - "extensions": map[string]interface{}{ - "persistedQuery": map[string]interface{}{ - "version": 1, - "sha256Hash": "fcad5a3e0d5af727fb76966f06971c19cfa2275e6ff7671196753e008611873c", - }, - }, - } - - data, err := client.Query(payload) - if err != nil { - return nil, fmt.Errorf("failed to query search: %w", err) - } - - filteredData := FilterSearch(data, c.Separator) - - jsonData, err := json.Marshal(filteredData) - if err != nil { - return nil, fmt.Errorf("failed to marshal filtered data: %w", err) - } - - var apiResp apiSearchResponse - if err := json.Unmarshal(jsonData, &apiResp); err != nil { - return nil, fmt.Errorf("failed to unmarshal to apiSearchResponse: %w", err) - } - - response := &SearchResponse{ - Tracks: make([]SearchResult, 0), - Albums: make([]SearchResult, 0), - Artists: make([]SearchResult, 0), - Playlists: make([]SearchResult, 0), - } - - for _, item := range apiResp.Results.Tracks { - response.Tracks = append(response.Tracks, SearchResult{ - ID: item.ID, - Name: item.Name, - Type: "track", - Artists: item.Artists, - AlbumName: item.Album, - Images: item.Cover, - ExternalURL: fmt.Sprintf("https://open.spotify.com/track/%s", item.ID), - Duration: parseDuration(item.Duration), - IsExplicit: item.IsExplicit, - }) - } - - for _, item := range apiResp.Results.Albums { - response.Albums = append(response.Albums, SearchResult{ - ID: item.ID, - Name: item.Name, - Type: "album", - Artists: item.Artists, - Images: item.Cover, - ReleaseDate: fmt.Sprintf("%d", item.Year), - ExternalURL: fmt.Sprintf("https://open.spotify.com/album/%s", item.ID), - }) - } - - for _, item := range apiResp.Results.Artists { - response.Artists = append(response.Artists, SearchResult{ - ID: item.ID, - Name: item.Name, - Type: "artist", - Images: item.Cover, - ExternalURL: fmt.Sprintf("https://open.spotify.com/artist/%s", item.ID), - }) - } - - for _, item := range apiResp.Results.Playlists { - response.Playlists = append(response.Playlists, SearchResult{ - ID: item.ID, - Name: item.Name, - Type: "playlist", - Images: item.Cover, - Owner: item.Owner, - ExternalURL: fmt.Sprintf("https://open.spotify.com/playlist/%s", item.ID), - }) - } - - return response, nil -} - -func SearchSpotify(ctx context.Context, query string, limit int) (*SearchResponse, error) { - client := NewSpotifyMetadataClient() - return client.Search(ctx, query, limit) -} - -func (c *SpotifyMetadataClient) SearchByType(ctx context.Context, query string, searchType string, limit int, offset int) ([]SearchResult, error) { - if query == "" { - return nil, errors.New("search query cannot be empty") - } - - if limit <= 0 || limit > 50 { - limit = 50 - } - - if offset < 0 { - offset = 0 - } - - client := NewSpotifyClient() - if err := client.Initialize(); err != nil { - return nil, fmt.Errorf("failed to initialize spotify client: %w", err) - } - - payload := map[string]interface{}{ - "variables": map[string]interface{}{ - "searchTerm": query, - "offset": offset, - "limit": limit, - "numberOfTopResults": 5, - "includeAudiobooks": true, - "includeArtistHasConcertsField": false, - "includePreReleases": true, - "includeAuthors": false, - }, - "operationName": "searchDesktop", - "extensions": map[string]interface{}{ - "persistedQuery": map[string]interface{}{ - "version": 1, - "sha256Hash": "fcad5a3e0d5af727fb76966f06971c19cfa2275e6ff7671196753e008611873c", - }, - }, - } - - data, err := client.Query(payload) - if err != nil { - return nil, fmt.Errorf("failed to query search: %w", err) - } - - filteredData := FilterSearch(data, c.Separator) - - jsonData, err := json.Marshal(filteredData) - if err != nil { - return nil, fmt.Errorf("failed to marshal filtered data: %w", err) - } - - var apiResp apiSearchResponse - if err := json.Unmarshal(jsonData, &apiResp); err != nil { - return nil, fmt.Errorf("failed to unmarshal to apiSearchResponse: %w", err) - } - - results := make([]SearchResult, 0) - - switch searchType { - case "track": - for _, item := range apiResp.Results.Tracks { - results = append(results, SearchResult{ - ID: item.ID, - Name: item.Name, - Type: "track", - Artists: item.Artists, - AlbumName: item.Album, - Images: item.Cover, - ExternalURL: fmt.Sprintf("https://open.spotify.com/track/%s", item.ID), - Duration: parseDuration(item.Duration), - IsExplicit: item.IsExplicit, - }) - } - case "album": - for _, item := range apiResp.Results.Albums { - results = append(results, SearchResult{ - ID: item.ID, - Name: item.Name, - Type: "album", - Artists: item.Artists, - Images: item.Cover, - ReleaseDate: fmt.Sprintf("%d", item.Year), - ExternalURL: fmt.Sprintf("https://open.spotify.com/album/%s", item.ID), - }) - } - case "artist": - for _, item := range apiResp.Results.Artists { - results = append(results, SearchResult{ - ID: item.ID, - Name: item.Name, - Type: "artist", - Images: item.Cover, - ExternalURL: fmt.Sprintf("https://open.spotify.com/artist/%s", item.ID), - }) - } - case "playlist": - for _, item := range apiResp.Results.Playlists { - results = append(results, SearchResult{ - ID: item.ID, - Name: item.Name, - Type: "playlist", - Images: item.Cover, - Owner: item.Owner, - ExternalURL: fmt.Sprintf("https://open.spotify.com/playlist/%s", item.ID), - }) - } - default: - return nil, fmt.Errorf("invalid search type: %s", searchType) - } - - return results, nil -} - -func SearchSpotifyByType(ctx context.Context, query string, searchType string, limit int, offset int) ([]SearchResult, error) { - client := NewSpotifyMetadataClient() - return client.SearchByType(ctx, query, searchType, limit, offset) -} - -func GetPreviewURL(trackID string) (string, error) { - if trackID == "" { - return "", errors.New("track ID cannot be empty") - } - - embedURL := fmt.Sprintf("https://open.spotify.com/embed/track/%s", trackID) - - client := &http.Client{Timeout: 15 * time.Second} - resp, err := client.Get(embedURL) - if err != nil { - return "", fmt.Errorf("failed to fetch embed page: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - return "", fmt.Errorf("embed page returned status %d", resp.StatusCode) - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return "", fmt.Errorf("failed to read response body: %w", err) - } - - html := string(body) - re := regexp.MustCompile(`https://p\.scdn\.co/mp3-preview/[a-zA-Z0-9]+`) - match := re.FindString(html) - - if match == "" { - return "", errors.New("preview URL not found") - } - - return match, nil -} diff --git a/backend/spotify_totp.go b/backend/spotify_totp.go deleted file mode 100644 index 3f5faa5..0000000 --- a/backend/spotify_totp.go +++ /dev/null @@ -1,28 +0,0 @@ -package backend - -import ( - "fmt" - "time" - - "github.com/pquerna/otp" - "github.com/pquerna/otp/totp" -) - -const ( - spotifyTOTPSecret = "GM3TMMJTGYZTQNZVGM4DINJZHA4TGOBYGMZTCMRTGEYDSMJRHE4TEOBUG4YTCMRUGQ4DQOJUGQYTAMRRGA2TCMJSHE3TCMBY" - spotifyTOTPVersion = 61 -) - -func generateSpotifyTOTP(now time.Time) (string, int, error) { - key, err := otp.NewKeyFromURL(fmt.Sprintf("otpauth://totp/secret?secret=%s", spotifyTOTPSecret)) - if err != nil { - return "", 0, err - } - - code, err := totp.GenerateCode(key.Secret(), now) - if err != nil { - return "", 0, err - } - - return code, spotifyTOTPVersion, nil -} diff --git a/backend/tidal.go b/backend/tidal.go deleted file mode 100644 index fec2b64..0000000 --- a/backend/tidal.go +++ /dev/null @@ -1,956 +0,0 @@ -package backend - -import ( - "encoding/base64" - "encoding/json" - "encoding/xml" - "fmt" - "io" - "net/http" - "os" - "os/exec" - "path/filepath" - "regexp" - "strings" - "time" -) - -type TidalDownloader struct { - client *http.Client - timeout time.Duration - maxRetries int - apiURL string -} - -type TidalAPIResponse struct { - OriginalTrackURL string `json:"OriginalTrackUrl"` -} - -type TidalAPIResponseV2 struct { - Version string `json:"version"` - Data struct { - TrackID int64 `json:"trackId"` - AssetPresentation string `json:"assetPresentation"` - AudioMode string `json:"audioMode"` - AudioQuality string `json:"audioQuality"` - ManifestMimeType string `json:"manifestMimeType"` - ManifestHash string `json:"manifestHash"` - Manifest string `json:"manifest"` - BitDepth int `json:"bitDepth"` - SampleRate int `json:"sampleRate"` - } `json:"data"` -} - -type TidalBTSManifest struct { - MimeType string `json:"mimeType"` - Codecs string `json:"codecs"` - EncryptionType string `json:"encryptionType"` - URLs []string `json:"urls"` -} - -func getConfiguredTidalAPIAttemptList() ([]string, error) { - customAPI := GetCustomTidalAPISetting() - if customAPI == "" { - return nil, fmt.Errorf("no configured custom tidal api instance") - } - return []string{customAPI}, nil -} - -func buildTidalOutputPath(outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyTrackNumber, spotifyDiscNumber int, isrcOverride string, useFirstArtistOnly bool) (string, bool, error) { - if outputDir != "." { - if err := os.MkdirAll(outputDir, 0755); err != nil { - return "", false, fmt.Errorf("directory error: %w", err) - } - } - - artistNameForFile := sanitizeFilename(spotifyArtistName) - albumArtistForFile := sanitizeFilename(spotifyAlbumArtist) - if useFirstArtistOnly { - artistNameForFile = sanitizeFilename(GetFirstArtist(spotifyArtistName)) - albumArtistForFile = sanitizeFilename(GetFirstArtist(spotifyAlbumArtist)) - } - - trackTitleForFile := sanitizeFilename(spotifyTrackName) - albumTitleForFile := sanitizeFilename(spotifyAlbumName) - - filename := buildTidalFilename(trackTitleForFile, artistNameForFile, albumTitleForFile, albumArtistForFile, spotifyReleaseDate, spotifyTrackNumber, spotifyDiscNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber, isrcOverride) - outputFilename := filepath.Join(outputDir, filename) - - outputFilename, alreadyExists := ResolveOutputPathForDownload(outputFilename, GetRedownloadWithSuffixSetting()) - return outputFilename, alreadyExists, nil -} - -func finalizeTidalDownload(outputFilename, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL string, useSingleGenre bool, embedGenre bool) { - trackTitle := spotifyTrackName - artistName := spotifyArtistName - albumTitle := spotifyAlbumName - - 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 != "" { - if ShouldSkipMusicBrainzMetadataFetch() { - fmt.Println("Skipping MusicBrainz metadata fetch because status check is offline.") - } else { - 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) - } - - isrc := strings.TrimSpace(isrcOverride) - var mbMeta Metadata - if spotifyURL != "" { - result := <-metaChan - if isrc == "" { - isrc = result.ISRC - } - mbMeta = result.Metadata - } - - upc := "" - if spotifyURL != "" { - if identifiers, err := GetSpotifyTrackIdentifiersDirect(spotifyURL); err == nil || identifiers.ISRC != "" || identifiers.UPC != "" { - if strings.TrimSpace(isrc) == "" && strings.TrimSpace(identifiers.ISRC) != "" { - isrc = strings.TrimSpace(identifiers.ISRC) - } - upc = strings.TrimSpace(identifiers.UPC) - } - } - - fmt.Println("Adding metadata...") - - coverPath := "" - if spotifyCoverURL != "" { - coverPath = outputFilename + ".cover.jpg" - coverClient := NewCoverClient() - if err := coverClient.DownloadCoverToPath(spotifyCoverURL, coverPath, embedMaxQualityCover); err != nil { - fmt.Printf("Warning: Failed to download Spotify cover: %v\n", err) - coverPath = "" - } else { - defer os.Remove(coverPath) - fmt.Println("Spotify cover downloaded") - } - } - - trackNumberToEmbed := spotifyTrackNumber - if trackNumberToEmbed == 0 { - trackNumberToEmbed = 1 - } - - metadata := Metadata{ - Title: trackTitle, - Artist: artistName, - Album: albumTitle, - AlbumArtist: spotifyAlbumArtist, - Date: spotifyReleaseDate, - TrackNumber: trackNumberToEmbed, - TotalTracks: spotifyTotalTracks, - DiscNumber: spotifyDiscNumber, - TotalDiscs: spotifyTotalDiscs, - URL: spotifyURL, - Comment: spotifyURL, - Copyright: spotifyCopyright, - Publisher: spotifyPublisher, - Composer: spotifyComposer, - Separator: metadataSeparator, - Description: "https://github.com/spotbye/SpotiFLAC", - ISRC: isrc, - UPC: upc, - Genre: mbMeta.Genre, - } - - if err := EmbedMetadata(outputFilename, metadata, coverPath); err != nil { - fmt.Printf("Tagging failed: %v\n", err) - } else { - fmt.Println("Metadata saved") - } -} - -func NewTidalDownloader(apiURL string) *TidalDownloader { - apiURL = strings.TrimRight(strings.TrimSpace(apiURL), "/") - return &TidalDownloader{ - client: &http.Client{ - Timeout: 5 * time.Second, - }, - timeout: 5 * time.Second, - maxRetries: 3, - apiURL: apiURL, - } -} - -func (t *TidalDownloader) GetAvailableAPIs() ([]string, error) { - apis, err := getConfiguredTidalAPIAttemptList() - if err == nil && len(apis) > 0 { - return apis, nil - } - - return nil, err -} - -func (t *TidalDownloader) GetTidalURLFromSpotify(spotifyTrackID string) (string, error) { - fmt.Println("Getting Tidal URL...") - client := NewSongLinkClient() - urls, err := client.GetAllURLsFromSpotify(spotifyTrackID, "") - if err != nil { - return "", fmt.Errorf("failed to get Tidal URL: %w", err) - } - - tidalURL := urls.TidalURL - if tidalURL == "" { - return "", fmt.Errorf("tidal link not found") - } - fmt.Printf("Found Tidal URL: %s\n", tidalURL) - return tidalURL, nil -} - -func (t *TidalDownloader) GetTrackIDFromURL(tidalURL string) (int64, error) { - - parts := strings.Split(tidalURL, "/track/") - if len(parts) < 2 { - return 0, fmt.Errorf("invalid tidal URL format") - } - - trackIDStr := strings.Split(parts[1], "?")[0] - trackIDStr = strings.TrimSpace(trackIDStr) - - var trackID int64 - _, err := fmt.Sscanf(trackIDStr, "%d", &trackID) - if err != nil { - return 0, fmt.Errorf("failed to parse track ID: %w", err) - } - - return trackID, nil -} - -func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (string, error) { - fmt.Println("Fetching URL...") - if strings.TrimSpace(t.apiURL) == "" { - return "", fmt.Errorf("no configured custom tidal api instance") - } - - url := fmt.Sprintf("%s/track/?id=%d&quality=%s", t.apiURL, trackID, quality) - fmt.Printf("Tidal API URL: %s\n", url) - - req, err := NewRequestWithDefaultHeaders(http.MethodGet, url, nil) - if err != nil { - fmt.Printf("✗ failed to create request: %v\n", err) - return "", fmt.Errorf("failed to create request: %w", err) - } - - resp, err := t.client.Do(req) - if err != nil { - fmt.Printf("✗ Tidal API request failed: %v\n", err) - return "", fmt.Errorf("failed to get download URL: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - fmt.Printf("✗ Tidal API returned status code: %d\n", resp.StatusCode) - return "", fmt.Errorf("API returned status code: %d", resp.StatusCode) - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - fmt.Printf("✗ Failed to read response body: %v\n", err) - return "", fmt.Errorf("failed to read response: %w", err) - } - - var v2Response TidalAPIResponseV2 - if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" { - fmt.Println("✓ Tidal manifest found (v2 API)") - return "MANIFEST:" + v2Response.Data.Manifest, nil - } - - var apiResponses []TidalAPIResponse - if err := json.Unmarshal(body, &apiResponses); err != nil { - - bodyStr := string(body) - if len(bodyStr) > 200 { - bodyStr = bodyStr[:200] + "..." - } - fmt.Printf("✗ Failed to decode Tidal API response: %v (response: %s)\n", err, bodyStr) - return "", fmt.Errorf("failed to decode response: %w (response: %s)", err, bodyStr) - } - - if len(apiResponses) == 0 { - fmt.Println("✗ Tidal API returned empty response") - return "", fmt.Errorf("no download URL in response") - } - - for _, item := range apiResponses { - if item.OriginalTrackURL != "" { - fmt.Println("✓ Tidal download URL found") - return item.OriginalTrackURL, nil - } - } - - fmt.Println("✗ No valid download URL in Tidal API response") - return "", fmt.Errorf("download URL not found in response") -} - -func (t *TidalDownloader) DownloadFile(url, filepath string, quality string) error { - - if strings.HasPrefix(url, "MANIFEST:") { - return t.DownloadFromManifest(strings.TrimPrefix(url, "MANIFEST:"), filepath, quality) - } - - req, err := NewRequestWithDefaultHeaders(http.MethodGet, url, nil) - if err != nil { - return fmt.Errorf("failed to create request: %w", err) - } - - resp, err := t.client.Do(req) - - if err != nil { - return fmt.Errorf("failed to download file: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - return fmt.Errorf("download failed with status %d", resp.StatusCode) - } - - out, err := os.Create(filepath) - if err != nil { - return fmt.Errorf("failed to create file: %w", err) - } - defer out.Close() - - pw := NewProgressWriter(out) - _, err = io.Copy(pw, resp.Body) - if err != nil { - return fmt.Errorf("failed to write file: %w", err) - } - - fmt.Printf("\rDownloaded: %.2f MB (Complete)\n", float64(pw.GetTotal())/(1024*1024)) - - fmt.Println("Download complete") - return nil -} - -func (t *TidalDownloader) DownloadFromManifest(manifestB64, outputPath string, quality string) error { - directURL, initURL, mediaURLs, mimeType, err := parseManifest(manifestB64) - if err != nil { - return fmt.Errorf("failed to parse manifest: %w", err) - } - - isLosslessRequested := quality == "LOSSLESS" || quality == "HI_RES" || quality == "HI_RES_LOSSLESS" - isActualLossless := strings.Contains(strings.ToLower(mimeType), "flac") || mimeType == "" - if isLosslessRequested && !isActualLossless { - return fmt.Errorf("requested %s quality but Tidal provided lossy format (%s). Aborting download", quality, mimeType) - } - - client := &http.Client{ - Timeout: 120 * time.Second, - } - - doRequest := func(url string) (*http.Response, error) { - req, err := NewRequestWithDefaultHeaders(http.MethodGet, url, nil) - if err != nil { - return nil, err - } - return client.Do(req) - } - - if directURL != "" && (strings.Contains(strings.ToLower(mimeType), "flac") || mimeType == "") { - fmt.Println("Downloading file...") - - resp, err := doRequest(directURL) - if err != nil { - return fmt.Errorf("failed to download file: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - return fmt.Errorf("download failed with status %d", resp.StatusCode) - } - - out, err := os.Create(outputPath) - if err != nil { - return fmt.Errorf("failed to create file: %w", err) - } - defer out.Close() - - pw := NewProgressWriter(out) - _, err = io.Copy(pw, resp.Body) - if err != nil { - return fmt.Errorf("failed to write file: %w", err) - } - - fmt.Printf("\rDownloaded: %.2f MB (Complete)\n", float64(pw.GetTotal())/(1024*1024)) - fmt.Println("Download complete") - return nil - } - - tempPath := outputPath + ".m4a.tmp" - - if directURL != "" { - fmt.Printf("Downloading non-FLAC file (%s)...\n", mimeType) - - resp, err := doRequest(directURL) - if err != nil { - return fmt.Errorf("failed to download file: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - return fmt.Errorf("download failed with status %d", resp.StatusCode) - } - - out, err := os.Create(tempPath) - if err != nil { - return fmt.Errorf("failed to create temp file: %w", err) - } - - pw := NewProgressWriter(out) - _, err = io.Copy(pw, resp.Body) - out.Close() - - if err != nil { - os.Remove(tempPath) - return fmt.Errorf("failed to write temp file: %w", err) - } - - fmt.Printf("\rDownloaded: %.2f MB (Complete)\n", float64(pw.GetTotal())/(1024*1024)) - - } else { - - fmt.Printf("Downloading %d segments...\n", len(mediaURLs)+1) - - out, err := os.Create(tempPath) - if err != nil { - return fmt.Errorf("failed to create temp file: %w", err) - } - - fmt.Print("Downloading init segment... ") - resp, err := doRequest(initURL) - if err != nil { - out.Close() - os.Remove(tempPath) - return fmt.Errorf("failed to download init segment: %w", err) - } - if resp.StatusCode != 200 { - resp.Body.Close() - out.Close() - os.Remove(tempPath) - return fmt.Errorf("init segment download failed with status %d", resp.StatusCode) - } - _, err = io.Copy(out, resp.Body) - resp.Body.Close() - if err != nil { - out.Close() - os.Remove(tempPath) - return fmt.Errorf("failed to write init segment: %w", err) - } - fmt.Println("OK") - - totalSegments := len(mediaURLs) - var totalBytes int64 - lastTime := time.Now() - var lastBytes int64 - for i, mediaURL := range mediaURLs { - resp, err := doRequest(mediaURL) - if err != nil { - out.Close() - os.Remove(tempPath) - return fmt.Errorf("failed to download segment %d: %w", i+1, err) - } - if resp.StatusCode != 200 { - resp.Body.Close() - out.Close() - os.Remove(tempPath) - return fmt.Errorf("segment %d download failed with status %d", i+1, resp.StatusCode) - } - n, err := io.Copy(out, resp.Body) - totalBytes += n - resp.Body.Close() - if err != nil { - out.Close() - os.Remove(tempPath) - return fmt.Errorf("failed to write segment %d: %w", i+1, err) - } - - mbDownloaded := float64(totalBytes) / (1024 * 1024) - now := time.Now() - timeDiff := now.Sub(lastTime).Seconds() - var speedMBps float64 - if timeDiff > 0.1 { - bytesDiff := float64(totalBytes - lastBytes) - speedMBps = (bytesDiff / (1024 * 1024)) / timeDiff - SetDownloadSpeed(speedMBps) - lastTime = now - lastBytes = totalBytes - } - SetDownloadProgress(mbDownloaded) - - fmt.Printf("\rDownloading: %.2f MB (%d/%d segments)", mbDownloaded, i+1, totalSegments) - } - - out.Close() - - tempInfo, _ := os.Stat(tempPath) - fmt.Printf("\rDownloaded: %.2f MB (Complete) \n", float64(tempInfo.Size())/(1024*1024)) - } - - fmt.Println("Converting to FLAC...") - ffmpegPath, err := GetFFmpegPath() - if err != nil { - return fmt.Errorf("ffmpeg not found: %w", err) - } - - if err := ValidateExecutable(ffmpegPath); err != nil { - return fmt.Errorf("invalid ffmpeg executable: %w", err) - } - - cmd := exec.Command(ffmpegPath, "-y", "-i", tempPath, "-vn", "-c:a", "flac", outputPath) - setHideWindow(cmd) - var stderr strings.Builder - cmd.Stderr = &stderr - if err := cmd.Run(); err != nil { - - m4aPath := strings.TrimSuffix(outputPath, ".flac") + ".m4a" - os.Rename(tempPath, m4aPath) - return fmt.Errorf("ffmpeg conversion failed (M4A saved as %s): %w - %s", m4aPath, err, stderr.String()) - } - - os.Remove(tempPath) - fmt.Println("Download complete") - - 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, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) { - fmt.Printf("Using Tidal URL: %s\n", tidalURL) - - trackID, err := t.GetTrackIDFromURL(tidalURL) - if err != nil { - return "", err - } - - if trackID == 0 { - return "", fmt.Errorf("no track ID found") - } - - outputFilename, alreadyExists, err := buildTidalOutputPath(outputDir, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyTrackNumber, spotifyDiscNumber, isrcOverride, useFirstArtistOnly) - if err != nil { - return "", err - } - if alreadyExists { - fmt.Printf("File already exists: %s (%.2f MB)\n", outputFilename, float64(mustFileSize(outputFilename))/(1024*1024)) - return "EXISTS:" + outputFilename, nil - } - - downloadURL, err := t.GetDownloadURL(trackID, quality) - if err != nil { - if isTidalHiResQuality(quality) && allowFallback { - fmt.Println("⚠ HI_RES unavailable/failed, falling back to LOSSLESS...") - downloadURL, err = t.GetDownloadURL(trackID, "LOSSLESS") - if err != nil { - return outputFilename, fmt.Errorf("failed to get download URL (HI_RES & LOSSLESS both failed): %w", err) - } - } else { - return outputFilename, err - } - } - - fmt.Printf("Downloading to: %s\n", outputFilename) - if err := t.DownloadFile(downloadURL, outputFilename, quality); err != nil { - cleanupTidalDownloadArtifacts(outputFilename) - return outputFilename, err - } - - finalizeTidalDownload(outputFilename, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL, useSingleGenre, embedGenre) - - fmt.Println("Done") - fmt.Println("✓ Downloaded successfully from Tidal") - 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, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) { - fmt.Printf("Using Tidal URL: %s\n", tidalURL) - - trackID, err := t.GetTrackIDFromURL(tidalURL) - if err != nil { - return "", err - } - - if trackID == 0 { - return "", fmt.Errorf("no track ID found") - } - - outputFilename, alreadyExists, err := buildTidalOutputPath(outputDir, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyTrackNumber, spotifyDiscNumber, isrcOverride, useFirstArtistOnly) - if err != nil { - return "", err - } - if alreadyExists { - fmt.Printf("File already exists: %s (%.2f MB)\n", outputFilename, float64(mustFileSize(outputFilename))/(1024*1024)) - return "EXISTS:" + outputFilename, nil - } - - fmt.Printf("Downloading to: %s\n", outputFilename) - successAPI, err := t.downloadWithRotatingAPIs(trackID, outputFilename, quality, allowFallback) - if err != nil { - cleanupTidalDownloadArtifacts(outputFilename) - return outputFilename, err - } - fmt.Printf("✓ Downloaded using API: %s\n", successAPI) - - finalizeTidalDownload(outputFilename, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL, useSingleGenre, embedGenre) - - fmt.Println("Done") - fmt.Println("✓ Downloaded successfully from Tidal") - 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, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) { - - tidalURL, err := t.GetTidalURLFromSpotify(spotifyTrackID) - if err != nil { - return "", fmt.Errorf("songlink/songstats couldn't find Tidal URL: %w", err) - } - - if t.apiURL == "" { - return "", fmt.Errorf("no configured custom tidal api instance") - } - return t.DownloadByURL(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL, allowFallback, useFirstArtistOnly, useSingleGenre, embedGenre) -} - -type SegmentTemplate struct { - Initialization string `xml:"initialization,attr"` - Media string `xml:"media,attr"` - Timeline struct { - Segments []struct { - Duration int64 `xml:"d,attr"` - Repeat int `xml:"r,attr"` - } `xml:"S"` - } `xml:"SegmentTimeline"` -} - -type MPD struct { - XMLName xml.Name `xml:"MPD"` - Period struct { - AdaptationSets []struct { - MimeType string `xml:"mimeType,attr"` - Codecs string `xml:"codecs,attr"` - Representations []struct { - ID string `xml:"id,attr"` - Codecs string `xml:"codecs,attr"` - Bandwidth int `xml:"bandwidth,attr"` - SegmentTemplate *SegmentTemplate `xml:"SegmentTemplate"` - } `xml:"Representation"` - SegmentTemplate *SegmentTemplate `xml:"SegmentTemplate"` - } `xml:"AdaptationSet"` - } `xml:"Period"` -} - -func parseManifest(manifestB64 string) (directURL string, initURL string, mediaURLs []string, mimeType string, err error) { - manifestBytes, err := base64.StdEncoding.DecodeString(manifestB64) - if err != nil { - return "", "", nil, "", fmt.Errorf("failed to decode manifest: %w", err) - } - - manifestStr := string(manifestBytes) - - if strings.HasPrefix(strings.TrimSpace(manifestStr), "{") { - var btsManifest TidalBTSManifest - if err := json.Unmarshal(manifestBytes, &btsManifest); err != nil { - return "", "", nil, "", fmt.Errorf("failed to parse BTS manifest: %w", err) - } - - if len(btsManifest.URLs) == 0 { - return "", "", nil, "", fmt.Errorf("no URLs in BTS manifest") - } - - fmt.Printf("Manifest: BTS format (%s, %s)\n", btsManifest.MimeType, btsManifest.Codecs) - return btsManifest.URLs[0], "", nil, btsManifest.MimeType, nil - } - - fmt.Println("Manifest: DASH format") - - var mpd MPD - var segTemplate *SegmentTemplate - var dashMimeType string - - if err := xml.Unmarshal(manifestBytes, &mpd); err == nil { - var selectedBandwidth int - var selectedCodecs string - var selectedMimeType string - - for _, as := range mpd.Period.AdaptationSets { - - if as.SegmentTemplate != nil { - - if segTemplate == nil { - segTemplate = as.SegmentTemplate - selectedCodecs = as.Codecs - selectedMimeType = as.MimeType - } - } - - for _, rep := range as.Representations { - if rep.SegmentTemplate != nil { - if rep.Bandwidth > selectedBandwidth { - selectedBandwidth = rep.Bandwidth - segTemplate = rep.SegmentTemplate - - if rep.Codecs != "" { - selectedCodecs = rep.Codecs - } else { - selectedCodecs = as.Codecs - } - - selectedMimeType = as.MimeType - } - } - } - } - - if selectedBandwidth > 0 { - fmt.Printf("Selected stream: Codec=%s, Bandwidth=%d bps\n", selectedCodecs, selectedBandwidth) - dashMimeType = fmt.Sprintf("%s; codecs=\"%s\"", selectedMimeType, selectedCodecs) - } - } - - var mediaTemplate string - segmentCount := 0 - - if segTemplate != nil { - initURL = segTemplate.Initialization - mediaTemplate = segTemplate.Media - - for _, seg := range segTemplate.Timeline.Segments { - segmentCount += seg.Repeat + 1 - } - } - - if segmentCount > 0 && initURL != "" && mediaTemplate != "" { - initURL = strings.ReplaceAll(initURL, "&", "&") - mediaTemplate = strings.ReplaceAll(mediaTemplate, "&", "&") - - fmt.Printf("Parsed manifest via XML: %d segments\n", segmentCount) - - for i := 1; i <= segmentCount; i++ { - mediaURL := strings.ReplaceAll(mediaTemplate, "$Number$", fmt.Sprintf("%d", i)) - mediaURLs = append(mediaURLs, mediaURL) - } - return "", initURL, mediaURLs, dashMimeType, nil - } - - fmt.Println("Using regex fallback for DASH manifest...") - - initRe := regexp.MustCompile(`initialization="([^"]+)"`) - mediaRe := regexp.MustCompile(`media="([^"]+)"`) - - if match := initRe.FindStringSubmatch(manifestStr); len(match) > 1 { - initURL = match[1] - } - if match := mediaRe.FindStringSubmatch(manifestStr); len(match) > 1 { - mediaTemplate = match[1] - } - - if initURL == "" { - return "", "", nil, "", fmt.Errorf("no initialization URL found in manifest") - } - - initURL = strings.ReplaceAll(initURL, "&", "&") - mediaTemplate = strings.ReplaceAll(mediaTemplate, "&", "&") - - segmentCount = 0 - - segTagRe := regexp.MustCompile(`]*>`) - matches := segTagRe.FindAllString(manifestStr, -1) - - for _, match := range matches { - repeat := 0 - rRe := regexp.MustCompile(`r="(\d+)"`) - if rMatch := rRe.FindStringSubmatch(match); len(rMatch) > 1 { - fmt.Sscanf(rMatch[1], "%d", &repeat) - } - segmentCount += repeat + 1 - } - - if segmentCount == 0 { - return "", "", nil, "", fmt.Errorf("no segments found in manifest (XML: %d, Regex: 0)", len(matches)) - } - - fmt.Printf("Parsed manifest via Regex: %d segments\n", segmentCount) - - for i := 1; i <= segmentCount; i++ { - mediaURL := strings.ReplaceAll(mediaTemplate, "$Number$", fmt.Sprintf("%d", i)) - mediaURLs = append(mediaURLs, mediaURL) - } - - return "", initURL, mediaURLs, dashMimeType, nil -} - -func (t *TidalDownloader) downloadWithRotatingAPIs(trackID int64, outputFilename string, quality string, allowFallback bool) (string, error) { - qualities := []string{quality} - if isTidalHiResQuality(quality) && allowFallback { - qualities = append(qualities, "LOSSLESS") - } - - var lastErr error - for idx, candidateQuality := range qualities { - if idx > 0 { - fmt.Printf("⚠ %s unavailable/failed on all APIs, falling back to %s...\n", quality, candidateQuality) - } - - apiURL, err := t.tryDownloadAcrossTidalAPIs(trackID, outputFilename, candidateQuality, false) - if err == nil { - return apiURL, nil - } - lastErr = err - } - - if lastErr == nil { - lastErr = fmt.Errorf("no tidal api succeeded") - } - return "", lastErr -} - -func (t *TidalDownloader) tryDownloadAcrossTidalAPIs(trackID int64, outputFilename string, quality string, refreshed bool) (string, error) { - apis, err := getConfiguredTidalAPIAttemptList() - if err != nil && len(apis) == 0 { - return "", fmt.Errorf("failed to load tidal api list: %w", err) - } - if len(apis) == 0 { - return "", fmt.Errorf("no tidal apis available") - } - - var lastErr error - errors := make([]string, 0, len(apis)) - - for _, apiURL := range apis { - fmt.Printf("Trying Tidal API: %s\n", apiURL) - - downloader := NewTidalDownloader(apiURL) - downloadURL, err := downloader.GetDownloadURL(trackID, quality) - if err != nil { - lastErr = err - errors = append(errors, fmt.Sprintf("%s: %v", apiURL, err)) - continue - } - - if err := downloader.DownloadFile(downloadURL, outputFilename, quality); err != nil { - lastErr = err - cleanupTidalDownloadArtifacts(outputFilename) - errors = append(errors, fmt.Sprintf("%s: %v", apiURL, err)) - continue - } - - return apiURL, nil - } - - if lastErr == nil { - lastErr = fmt.Errorf("all tidal apis failed") - } - - fmt.Println("All Tidal APIs failed:") - for _, item := range errors { - fmt.Printf(" ✗ %s\n", item) - } - - return "", fmt.Errorf("all tidal apis failed for quality %s: %w", quality, lastErr) -} - -func cleanupTidalDownloadArtifacts(outputPath string) { - if outputPath == "" { - return - } - - _ = os.Remove(outputPath) - _ = os.Remove(outputPath + ".m4a.tmp") -} - -func isTidalHiResQuality(quality string) bool { - normalized := strings.TrimSpace(strings.ToUpper(quality)) - return normalized == "HI_RES" || normalized == "HI_RES_LOSSLESS" -} - -func buildTidalFilename(title, artist, album, albumArtist, releaseDate string, trackNumber, discNumber int, format string, includeTrackNumber bool, position int, useAlbumTrackNumber bool, extra ...string) string { - var filename string - isrc := "" - if len(extra) > 0 { - isrc = SanitizeOptionalFilename(extra[0]) - } - - numberToUse := position - if useAlbumTrackNumber && trackNumber > 0 { - numberToUse = trackNumber - } - - year := "" - if len(releaseDate) >= 4 { - year = releaseDate[:4] - } - - if strings.Contains(format, "{") { - filename = format - filename = strings.ReplaceAll(filename, "{title}", title) - filename = strings.ReplaceAll(filename, "{artist}", artist) - filename = strings.ReplaceAll(filename, "{album}", album) - filename = strings.ReplaceAll(filename, "{album_artist}", albumArtist) - filename = strings.ReplaceAll(filename, "{year}", year) - filename = strings.ReplaceAll(filename, "{date}", SanitizeFilename(releaseDate)) - filename = strings.ReplaceAll(filename, "{isrc}", isrc) - - if discNumber > 0 { - filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber)) - } else { - filename = strings.ReplaceAll(filename, "{disc}", "") - } - - if numberToUse > 0 { - filename = strings.ReplaceAll(filename, "{track}", fmt.Sprintf("%02d", numberToUse)) - } else { - - filename = regexp.MustCompile(`\{track\}\.\s*`).ReplaceAllString(filename, "") - filename = regexp.MustCompile(`\{track\}\s*-\s*`).ReplaceAllString(filename, "") - filename = regexp.MustCompile(`\{track\}\s*`).ReplaceAllString(filename, "") - } - } else { - - switch format { - case "artist-title": - filename = fmt.Sprintf("%s - %s", artist, title) - case "title": - filename = title - default: - filename = fmt.Sprintf("%s - %s", title, artist) - } - - if includeTrackNumber && position > 0 { - filename = fmt.Sprintf("%02d. %s", numberToUse, filename) - } - } - - return filename + ".flac" -} diff --git a/backend/upc_tags.go b/backend/upc_tags.go deleted file mode 100644 index 14a638f..0000000 --- a/backend/upc_tags.go +++ /dev/null @@ -1,50 +0,0 @@ -package backend - -import "strings" - -const preferredUPCTagKey = "UPC" - -var ffprobeUPCTagKeys = []string{ - "upc", - "barcode", - "wm/upc", - "txxx:upc", - "txxx:barcode", - "txxx/upc", - "txxx/barcode", - "----:com.apple.itunes:upc", - "----:com.apple.itunes:barcode", -} - -func assignPreferredUPC(current *string, incoming string, preferred bool) { - incoming = strings.TrimSpace(incoming) - if incoming == "" { - return - } - - if preferred || strings.TrimSpace(*current) == "" { - *current = incoming - } -} - -func classifyUPCDescription(description string) (matched bool, preferred bool) { - switch strings.ToUpper(strings.TrimSpace(description)) { - case preferredUPCTagKey: - return true, true - case "BARCODE": - return true, false - default: - return false, false - } -} - -func firstPreferredFFprobeUPCValue(tags map[string]string) string { - for _, key := range ffprobeUPCTagKeys { - value := strings.TrimSpace(tags[key]) - if value != "" { - return value - } - } - - return "" -} diff --git a/frontend/.gitignore b/frontend/.gitignore deleted file mode 100644 index a547bf3..0000000 --- a/frontend/.gitignore +++ /dev/null @@ -1,24 +0,0 @@ -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -pnpm-debug.log* -lerna-debug.log* - -node_modules -dist -dist-ssr -*.local - -# Editor directories and files -.vscode/* -!.vscode/extensions.json -.idea -.DS_Store -*.suo -*.ntvs* -*.njsproj -*.sln -*.sw? diff --git a/frontend/components.json b/frontend/components.json deleted file mode 100644 index fd90f3b..0000000 --- a/frontend/components.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "$schema": "https://ui.shadcn.com/schema.json", - "style": "new-york", - "rsc": false, - "tsx": true, - "tailwind": { - "config": "", - "css": "src/index.css", - "baseColor": "neutral", - "cssVariables": true, - "prefix": "" - }, - "iconLibrary": "lucide", - "aliases": { - "components": "@/components", - "utils": "@/lib/utils", - "ui": "@/components/ui", - "lib": "@/lib", - "hooks": "@/hooks" - }, - "registries": { - "@lucide-animated": "https://lucide-animated.com/r/{name}.json" - } -} diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js deleted file mode 100644 index 13d2e6b..0000000 --- a/frontend/eslint.config.js +++ /dev/null @@ -1,22 +0,0 @@ -import js from '@eslint/js'; -import globals from 'globals'; -import reactHooks from 'eslint-plugin-react-hooks'; -import reactRefresh from 'eslint-plugin-react-refresh'; -import tseslint from 'typescript-eslint'; -import { defineConfig, globalIgnores } from 'eslint/config'; -export default defineConfig([ - globalIgnores(['dist']), - { - files: ['**/*.{ts,tsx}'], - extends: [ - js.configs.recommended, - tseslint.configs.recommended, - reactHooks.configs.flat.recommended, - reactRefresh.configs.vite, - ], - languageOptions: { - ecmaVersion: 2020, - globals: globals.browser, - }, - }, -]); diff --git a/frontend/index.html b/frontend/index.html deleted file mode 100644 index 8257e4a..0000000 --- a/frontend/index.html +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - - - - - SpotiFLAC - - - -
- - - - \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json deleted file mode 100644 index 4972ef7..0000000 --- a/frontend/package.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "name": "frontend", - "private": true, - "version": "0.0.0", - "type": "module", - "scripts": { - "dev": "vite", - "build": "tsc -b && vite build", - "postinstall": "node scripts/generate-icon.js", - "lint": "eslint .", - "preview": "vite preview", - "generate-icon": "node scripts/generate-icon.js" - }, - "dependencies": { - "@radix-ui/react-checkbox": "^1.3.3", - "@radix-ui/react-context-menu": "^2.2.16", - "@radix-ui/react-dialog": "^1.1.15", - "@radix-ui/react-label": "^2.1.8", - "@radix-ui/react-menubar": "^1.1.16", - "@radix-ui/react-progress": "^1.1.8", - "@radix-ui/react-scroll-area": "^1.2.10", - "@radix-ui/react-select": "^2.2.6", - "@radix-ui/react-slider": "^1.3.6", - "@radix-ui/react-slot": "^1.2.4", - "@radix-ui/react-switch": "^1.2.6", - "@radix-ui/react-tabs": "^1.1.13", - "@radix-ui/react-toggle": "^1.1.10", - "@radix-ui/react-toggle-group": "^1.1.11", - "@radix-ui/react-tooltip": "^1.2.8", - "@tailwindcss/vite": "^4.2.1", - "class-variance-authority": "^0.7.1", - "clsx": "^2.1.1", - "lucide-react": "^0.575.0", - "motion": "^12.34.3", - "next-themes": "^0.4.6", - "radix-ui": "^1.4.3", - "react": "^19.2.4", - "react-dom": "^19.2.4", - "sonner": "^2.0.7", - "tailwind-merge": "^3.5.0", - "tailwindcss": "^4.2.1" - }, - "devDependencies": { - "@eslint/js": "^10.0.1", - "@types/node": "^25.3.0", - "@types/react": "^19.2.14", - "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^5.1.4", - "eslint": "^10.0.2", - "eslint-plugin-react-hooks": "^7.0.1", - "eslint-plugin-react-refresh": "^0.5.2", - "globals": "^17.3.0", - "sharp": "^0.34.5", - "tw-animate-css": "^1.4.0", - "typescript": "~5.9.3", - "typescript-eslint": "^8.56.1", - "vite": "^7.3.1" - } -} diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 deleted file mode 100644 index 806b9d6..0000000 --- a/frontend/package.json.md5 +++ /dev/null @@ -1 +0,0 @@ -8864b4f7b7971b624d1ba25030f2db4e \ No newline at end of file diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml deleted file mode 100644 index 2158658..0000000 --- a/frontend/pnpm-lock.yaml +++ /dev/null @@ -1,4519 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - dependencies: - '@radix-ui/react-checkbox': - specifier: ^1.3.3 - version: 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-context-menu': - specifier: ^2.2.16 - version: 2.2.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-dialog': - specifier: ^1.1.15 - version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-label': - specifier: ^2.1.8 - version: 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-menubar': - specifier: ^1.1.16 - version: 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-progress': - specifier: ^1.1.8 - version: 1.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-scroll-area': - specifier: ^1.2.10 - version: 1.2.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-select': - specifier: ^2.2.6 - version: 2.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-slider': - specifier: ^1.3.6 - version: 1.3.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-slot': - specifier: ^1.2.4 - version: 1.2.4(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-switch': - specifier: ^1.2.6 - version: 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-tabs': - specifier: ^1.1.13 - version: 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-toggle': - specifier: ^1.1.10 - version: 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-toggle-group': - specifier: ^1.1.11 - version: 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-tooltip': - specifier: ^1.2.8 - version: 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@tailwindcss/vite': - specifier: ^4.2.1 - version: 4.2.1(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)) - class-variance-authority: - specifier: ^0.7.1 - version: 0.7.1 - clsx: - specifier: ^2.1.1 - version: 2.1.1 - lucide-react: - specifier: ^0.575.0 - version: 0.575.0(react@19.2.4) - motion: - specifier: ^12.34.3 - version: 12.34.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - next-themes: - specifier: ^0.4.6 - version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - radix-ui: - specifier: ^1.4.3 - version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - react: - specifier: ^19.2.4 - version: 19.2.4 - react-dom: - specifier: ^19.2.4 - version: 19.2.4(react@19.2.4) - sonner: - specifier: ^2.0.7 - version: 2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - tailwind-merge: - specifier: ^3.5.0 - version: 3.5.0 - tailwindcss: - specifier: ^4.2.1 - version: 4.2.1 - devDependencies: - '@eslint/js': - specifier: ^10.0.1 - version: 10.0.1(eslint@10.0.2(jiti@2.6.1)) - '@types/node': - specifier: ^25.3.0 - version: 25.3.0 - '@types/react': - specifier: ^19.2.14 - version: 19.2.14 - '@types/react-dom': - specifier: ^19.2.3 - version: 19.2.3(@types/react@19.2.14) - '@vitejs/plugin-react': - specifier: ^5.1.4 - version: 5.1.4(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)) - eslint: - specifier: ^10.0.2 - version: 10.0.2(jiti@2.6.1) - eslint-plugin-react-hooks: - specifier: ^7.0.1 - version: 7.0.1(eslint@10.0.2(jiti@2.6.1)) - eslint-plugin-react-refresh: - specifier: ^0.5.2 - version: 0.5.2(eslint@10.0.2(jiti@2.6.1)) - globals: - specifier: ^17.3.0 - version: 17.3.0 - sharp: - specifier: ^0.34.5 - version: 0.34.5 - tw-animate-css: - specifier: ^1.4.0 - version: 1.4.0 - typescript: - specifier: ~5.9.3 - version: 5.9.3 - typescript-eslint: - specifier: ^8.56.1 - version: 8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) - vite: - specifier: ^7.3.1 - version: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1) - -packages: - - '@babel/code-frame@7.29.0': - resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} - engines: {node: '>=6.9.0'} - - '@babel/compat-data@7.29.0': - resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} - engines: {node: '>=6.9.0'} - - '@babel/core@7.29.0': - resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} - engines: {node: '>=6.9.0'} - - '@babel/generator@7.29.1': - resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-compilation-targets@7.28.6': - resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} - engines: {node: '>=6.9.0'} - - '@babel/helper-globals@7.28.0': - resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-module-imports@7.28.6': - resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-module-transforms@7.28.6': - resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - - '@babel/helper-plugin-utils@7.28.6': - resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} - engines: {node: '>=6.9.0'} - - '@babel/helper-string-parser@7.27.1': - resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} - engines: {node: '>=6.9.0'} - - '@babel/helper-validator-identifier@7.28.5': - resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} - engines: {node: '>=6.9.0'} - - '@babel/helper-validator-option@7.27.1': - resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} - engines: {node: '>=6.9.0'} - - '@babel/helpers@7.28.6': - resolution: {integrity: sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==} - engines: {node: '>=6.9.0'} - - '@babel/parser@7.29.0': - resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==} - engines: {node: '>=6.0.0'} - hasBin: true - - '@babel/plugin-transform-react-jsx-self@7.27.1': - resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-react-jsx-source@7.27.1': - resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/template@7.28.6': - resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} - engines: {node: '>=6.9.0'} - - '@babel/traverse@7.29.0': - resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} - engines: {node: '>=6.9.0'} - - '@babel/types@7.29.0': - resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} - engines: {node: '>=6.9.0'} - - '@emnapi/runtime@1.8.1': - resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==} - - '@esbuild/aix-ppc64@0.27.3': - resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - - '@esbuild/android-arm64@0.27.3': - resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - - '@esbuild/android-arm@0.27.3': - resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - - '@esbuild/android-x64@0.27.3': - resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - - '@esbuild/darwin-arm64@0.27.3': - resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - - '@esbuild/darwin-x64@0.27.3': - resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - - '@esbuild/freebsd-arm64@0.27.3': - resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - - '@esbuild/freebsd-x64@0.27.3': - resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - - '@esbuild/linux-arm64@0.27.3': - resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - - '@esbuild/linux-arm@0.27.3': - resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - - '@esbuild/linux-ia32@0.27.3': - resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - - '@esbuild/linux-loong64@0.27.3': - resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - - '@esbuild/linux-mips64el@0.27.3': - resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - - '@esbuild/linux-ppc64@0.27.3': - resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - - '@esbuild/linux-riscv64@0.27.3': - resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - - '@esbuild/linux-s390x@0.27.3': - resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - - '@esbuild/linux-x64@0.27.3': - resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - - '@esbuild/netbsd-arm64@0.27.3': - resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - - '@esbuild/netbsd-x64@0.27.3': - resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - - '@esbuild/openbsd-arm64@0.27.3': - resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - - '@esbuild/openbsd-x64@0.27.3': - resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - - '@esbuild/openharmony-arm64@0.27.3': - resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - - '@esbuild/sunos-x64@0.27.3': - resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - - '@esbuild/win32-arm64@0.27.3': - resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - - '@esbuild/win32-ia32@0.27.3': - resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - - '@esbuild/win32-x64@0.27.3': - resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - - '@eslint-community/eslint-utils@4.9.1': - resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - peerDependencies: - eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 - - '@eslint-community/regexpp@4.12.2': - resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} - engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - - '@eslint/config-array@0.23.2': - resolution: {integrity: sha512-YF+fE6LV4v5MGWRGj7G404/OZzGNepVF8fxk7jqmqo3lrza7a0uUcDnROGRBG1WFC1omYUS/Wp1f42i0M+3Q3A==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - '@eslint/config-helpers@0.5.2': - resolution: {integrity: sha512-a5MxrdDXEvqnIq+LisyCX6tQMPF/dSJpCfBgBauY+pNZ28yCtSsTvyTYrMhaI+LK26bVyCJfJkT0u8KIj2i1dQ==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - '@eslint/core@1.1.0': - resolution: {integrity: sha512-/nr9K9wkr3P1EzFTdFdMoLuo1PmIxjmwvPozwoSodjNBdefGujXQUF93u1DDZpEaTuDvMsIQddsd35BwtrW9Xw==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - '@eslint/js@10.0.1': - resolution: {integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - peerDependencies: - eslint: ^10.0.0 - peerDependenciesMeta: - eslint: - optional: true - - '@eslint/object-schema@3.0.2': - resolution: {integrity: sha512-HOy56KJt48Bx8KmJ+XGQNSUMT/6dZee/M54XyUyuvTvPXJmsERRvBchsUVx1UMe1WwIH49XLAczNC7V2INsuUw==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - '@eslint/plugin-kit@0.6.0': - resolution: {integrity: sha512-bIZEUzOI1jkhviX2cp5vNyXQc6olzb2ohewQubuYlMXZ2Q/XjBO0x0XhGPvc9fjSIiUN0vw+0hq53BJ4eQSJKQ==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - '@floating-ui/core@1.7.4': - resolution: {integrity: sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==} - - '@floating-ui/dom@1.7.5': - resolution: {integrity: sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==} - - '@floating-ui/react-dom@2.1.7': - resolution: {integrity: sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg==} - peerDependencies: - react: '>=16.8.0' - react-dom: '>=16.8.0' - - '@floating-ui/utils@0.2.10': - resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} - - '@humanfs/core@0.19.1': - resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} - engines: {node: '>=18.18.0'} - - '@humanfs/node@0.16.7': - resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==} - engines: {node: '>=18.18.0'} - - '@humanwhocodes/module-importer@1.0.1': - resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} - engines: {node: '>=12.22'} - - '@humanwhocodes/retry@0.4.3': - resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} - engines: {node: '>=18.18'} - - '@img/colour@1.0.0': - resolution: {integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==} - engines: {node: '>=18'} - - '@img/sharp-darwin-arm64@0.34.5': - resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [darwin] - - '@img/sharp-darwin-x64@0.34.5': - resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [darwin] - - '@img/sharp-libvips-darwin-arm64@1.2.4': - resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} - cpu: [arm64] - os: [darwin] - - '@img/sharp-libvips-darwin-x64@1.2.4': - resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} - cpu: [x64] - os: [darwin] - - '@img/sharp-libvips-linux-arm64@1.2.4': - resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linux-arm@1.2.4': - resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} - cpu: [arm] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linux-ppc64@1.2.4': - resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linux-riscv64@1.2.4': - resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} - cpu: [riscv64] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linux-s390x@1.2.4': - resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linux-x64@1.2.4': - resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linuxmusl-arm64@1.2.4': - resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@img/sharp-libvips-linuxmusl-x64@1.2.4': - resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} - cpu: [x64] - os: [linux] - libc: [musl] - - '@img/sharp-linux-arm64@0.34.5': - resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@img/sharp-linux-arm@0.34.5': - resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm] - os: [linux] - libc: [glibc] - - '@img/sharp-linux-ppc64@0.34.5': - resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@img/sharp-linux-riscv64@0.34.5': - resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [riscv64] - os: [linux] - libc: [glibc] - - '@img/sharp-linux-s390x@0.34.5': - resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@img/sharp-linux-x64@0.34.5': - resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@img/sharp-linuxmusl-arm64@0.34.5': - resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@img/sharp-linuxmusl-x64@0.34.5': - resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [linux] - libc: [musl] - - '@img/sharp-wasm32@0.34.5': - resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [wasm32] - - '@img/sharp-win32-arm64@0.34.5': - resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [win32] - - '@img/sharp-win32-ia32@0.34.5': - resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [ia32] - os: [win32] - - '@img/sharp-win32-x64@0.34.5': - resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [win32] - - '@jridgewell/gen-mapping@0.3.13': - resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} - - '@jridgewell/remapping@2.3.5': - resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} - - '@jridgewell/resolve-uri@3.1.2': - resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} - engines: {node: '>=6.0.0'} - - '@jridgewell/sourcemap-codec@1.5.5': - resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - - '@jridgewell/trace-mapping@0.3.31': - resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - - '@radix-ui/number@1.1.1': - resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} - - '@radix-ui/primitive@1.1.3': - resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} - - '@radix-ui/react-accessible-icon@1.1.7': - resolution: {integrity: sha512-XM+E4WXl0OqUJFovy6GjmxxFyx9opfCAIUku4dlKRd5YEPqt4kALOkQOp0Of6reHuUkJuiPBEc5k0o4z4lTC8A==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-accordion@1.2.12': - resolution: {integrity: sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-alert-dialog@1.1.15': - resolution: {integrity: sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-arrow@1.1.7': - resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-aspect-ratio@1.1.7': - resolution: {integrity: sha512-Yq6lvO9HQyPwev1onK1daHCHqXVLzPhSVjmsNjCa2Zcxy2f7uJD2itDtxknv6FzAKCwD1qQkeVDmX/cev13n/g==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-avatar@1.1.10': - resolution: {integrity: sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-checkbox@1.3.3': - resolution: {integrity: sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-collapsible@1.1.12': - resolution: {integrity: sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-collection@1.1.7': - resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-compose-refs@1.1.2': - resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-context-menu@2.2.16': - resolution: {integrity: sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-context@1.1.2': - resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-context@1.1.3': - resolution: {integrity: sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-dialog@1.1.15': - resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-direction@1.1.1': - resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-dismissable-layer@1.1.11': - resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-dropdown-menu@2.1.16': - resolution: {integrity: sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-focus-guards@1.1.3': - resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-focus-scope@1.1.7': - resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-form@0.1.8': - resolution: {integrity: sha512-QM70k4Zwjttifr5a4sZFts9fn8FzHYvQ5PiB19O2HsYibaHSVt9fH9rzB0XZo/YcM+b7t/p7lYCT/F5eOeF5yQ==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-hover-card@1.1.15': - resolution: {integrity: sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-id@1.1.1': - resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-label@2.1.7': - resolution: {integrity: sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-label@2.1.8': - resolution: {integrity: sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-menu@2.1.16': - resolution: {integrity: sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-menubar@1.1.16': - resolution: {integrity: sha512-EB1FktTz5xRRi2Er974AUQZWg2yVBb1yjip38/lgwtCVRd3a+maUoGHN/xs9Yv8SY8QwbSEb+YrxGadVWbEutA==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-navigation-menu@1.2.14': - resolution: {integrity: sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-one-time-password-field@0.1.8': - resolution: {integrity: sha512-ycS4rbwURavDPVjCb5iS3aG4lURFDILi6sKI/WITUMZ13gMmn/xGjpLoqBAalhJaDk8I3UbCM5GzKHrnzwHbvg==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-password-toggle-field@0.1.3': - resolution: {integrity: sha512-/UuCrDBWravcaMix4TdT+qlNdVwOM1Nck9kWx/vafXsdfj1ChfhOdfi3cy9SGBpWgTXwYCuboT/oYpJy3clqfw==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-popover@1.1.15': - resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-popper@1.2.8': - resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-portal@1.1.9': - resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-presence@1.1.5': - resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-primitive@2.1.3': - resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-primitive@2.1.4': - resolution: {integrity: sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-progress@1.1.7': - resolution: {integrity: sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-progress@1.1.8': - resolution: {integrity: sha512-+gISHcSPUJ7ktBy9RnTqbdKW78bcGke3t6taawyZ71pio1JewwGSJizycs7rLhGTvMJYCQB1DBK4KQsxs7U8dA==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-radio-group@1.3.8': - resolution: {integrity: sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-roving-focus@1.1.11': - resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-scroll-area@1.2.10': - resolution: {integrity: sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-select@2.2.6': - resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-separator@1.1.7': - resolution: {integrity: sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-slider@1.3.6': - resolution: {integrity: sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-slot@1.2.3': - resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-slot@1.2.4': - resolution: {integrity: sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-switch@1.2.6': - resolution: {integrity: sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-tabs@1.1.13': - resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-toast@1.2.15': - resolution: {integrity: sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-toggle-group@1.1.11': - resolution: {integrity: sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-toggle@1.1.10': - resolution: {integrity: sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-toolbar@1.1.11': - resolution: {integrity: sha512-4ol06/1bLoFu1nwUqzdD4Y5RZ9oDdKeiHIsntug54Hcr1pgaHiPqHFEaXI1IFP/EsOfROQZ8Mig9VTIRza6Tjg==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-tooltip@1.2.8': - resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-use-callback-ref@1.1.1': - resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-use-controllable-state@1.2.2': - resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-use-effect-event@0.0.2': - resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-use-escape-keydown@1.1.1': - resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-use-is-hydrated@0.1.0': - resolution: {integrity: sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-use-layout-effect@1.1.1': - resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-use-previous@1.1.1': - resolution: {integrity: sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-use-rect@1.1.1': - resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-use-size@1.1.1': - resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-visually-hidden@1.2.3': - resolution: {integrity: sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/rect@1.1.1': - resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} - - '@rolldown/pluginutils@1.0.0-rc.3': - resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==} - - '@rollup/rollup-android-arm-eabi@4.59.0': - resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==} - cpu: [arm] - os: [android] - - '@rollup/rollup-android-arm64@4.59.0': - resolution: {integrity: sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==} - cpu: [arm64] - os: [android] - - '@rollup/rollup-darwin-arm64@4.59.0': - resolution: {integrity: sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==} - cpu: [arm64] - os: [darwin] - - '@rollup/rollup-darwin-x64@4.59.0': - resolution: {integrity: sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==} - cpu: [x64] - os: [darwin] - - '@rollup/rollup-freebsd-arm64@4.59.0': - resolution: {integrity: sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==} - cpu: [arm64] - os: [freebsd] - - '@rollup/rollup-freebsd-x64@4.59.0': - resolution: {integrity: sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==} - cpu: [x64] - os: [freebsd] - - '@rollup/rollup-linux-arm-gnueabihf@4.59.0': - resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} - cpu: [arm] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-arm-musleabihf@4.59.0': - resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} - cpu: [arm] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-arm64-gnu@4.59.0': - resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-arm64-musl@4.59.0': - resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-loong64-gnu@4.59.0': - resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} - cpu: [loong64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-loong64-musl@4.59.0': - resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} - cpu: [loong64] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-ppc64-gnu@4.59.0': - resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-ppc64-musl@4.59.0': - resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} - cpu: [ppc64] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-riscv64-gnu@4.59.0': - resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} - cpu: [riscv64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-riscv64-musl@4.59.0': - resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} - cpu: [riscv64] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-s390x-gnu@4.59.0': - resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-x64-gnu@4.59.0': - resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-x64-musl@4.59.0': - resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} - cpu: [x64] - os: [linux] - libc: [musl] - - '@rollup/rollup-openbsd-x64@4.59.0': - resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} - cpu: [x64] - os: [openbsd] - - '@rollup/rollup-openharmony-arm64@4.59.0': - resolution: {integrity: sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==} - cpu: [arm64] - os: [openharmony] - - '@rollup/rollup-win32-arm64-msvc@4.59.0': - resolution: {integrity: sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==} - cpu: [arm64] - os: [win32] - - '@rollup/rollup-win32-ia32-msvc@4.59.0': - resolution: {integrity: sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==} - cpu: [ia32] - os: [win32] - - '@rollup/rollup-win32-x64-gnu@4.59.0': - resolution: {integrity: sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==} - cpu: [x64] - os: [win32] - - '@rollup/rollup-win32-x64-msvc@4.59.0': - resolution: {integrity: sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==} - cpu: [x64] - os: [win32] - - '@tailwindcss/node@4.2.1': - resolution: {integrity: sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==} - - '@tailwindcss/oxide-android-arm64@4.2.1': - resolution: {integrity: sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==} - engines: {node: '>= 20'} - cpu: [arm64] - os: [android] - - '@tailwindcss/oxide-darwin-arm64@4.2.1': - resolution: {integrity: sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==} - engines: {node: '>= 20'} - cpu: [arm64] - os: [darwin] - - '@tailwindcss/oxide-darwin-x64@4.2.1': - resolution: {integrity: sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==} - engines: {node: '>= 20'} - cpu: [x64] - os: [darwin] - - '@tailwindcss/oxide-freebsd-x64@4.2.1': - resolution: {integrity: sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==} - engines: {node: '>= 20'} - cpu: [x64] - os: [freebsd] - - '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1': - resolution: {integrity: sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==} - engines: {node: '>= 20'} - cpu: [arm] - os: [linux] - - '@tailwindcss/oxide-linux-arm64-gnu@4.2.1': - resolution: {integrity: sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==} - engines: {node: '>= 20'} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@tailwindcss/oxide-linux-arm64-musl@4.2.1': - resolution: {integrity: sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==} - engines: {node: '>= 20'} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@tailwindcss/oxide-linux-x64-gnu@4.2.1': - resolution: {integrity: sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==} - engines: {node: '>= 20'} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@tailwindcss/oxide-linux-x64-musl@4.2.1': - resolution: {integrity: sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==} - engines: {node: '>= 20'} - cpu: [x64] - os: [linux] - libc: [musl] - - '@tailwindcss/oxide-wasm32-wasi@4.2.1': - resolution: {integrity: sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==} - engines: {node: '>=14.0.0'} - cpu: [wasm32] - bundledDependencies: - - '@napi-rs/wasm-runtime' - - '@emnapi/core' - - '@emnapi/runtime' - - '@tybys/wasm-util' - - '@emnapi/wasi-threads' - - tslib - - '@tailwindcss/oxide-win32-arm64-msvc@4.2.1': - resolution: {integrity: sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==} - engines: {node: '>= 20'} - cpu: [arm64] - os: [win32] - - '@tailwindcss/oxide-win32-x64-msvc@4.2.1': - resolution: {integrity: sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==} - engines: {node: '>= 20'} - cpu: [x64] - os: [win32] - - '@tailwindcss/oxide@4.2.1': - resolution: {integrity: sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==} - engines: {node: '>= 20'} - - '@tailwindcss/vite@4.2.1': - resolution: {integrity: sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w==} - peerDependencies: - vite: ^5.2.0 || ^6 || ^7 - - '@types/babel__core@7.20.5': - resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} - - '@types/babel__generator@7.27.0': - resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} - - '@types/babel__template@7.4.4': - resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} - - '@types/babel__traverse@7.28.0': - resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} - - '@types/esrecurse@4.3.1': - resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} - - '@types/estree@1.0.8': - resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} - - '@types/json-schema@7.0.15': - resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - - '@types/node@25.3.0': - resolution: {integrity: sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==} - - '@types/react-dom@19.2.3': - resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} - peerDependencies: - '@types/react': ^19.2.0 - - '@types/react@19.2.14': - resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} - - '@typescript-eslint/eslint-plugin@8.56.1': - resolution: {integrity: sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - '@typescript-eslint/parser': ^8.56.1 - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.0.0' - - '@typescript-eslint/parser@8.56.1': - resolution: {integrity: sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.0.0' - - '@typescript-eslint/project-service@8.56.1': - resolution: {integrity: sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.0.0' - - '@typescript-eslint/scope-manager@8.56.1': - resolution: {integrity: sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@typescript-eslint/tsconfig-utils@8.56.1': - resolution: {integrity: sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.0.0' - - '@typescript-eslint/type-utils@8.56.1': - resolution: {integrity: sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.0.0' - - '@typescript-eslint/types@8.56.1': - resolution: {integrity: sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@typescript-eslint/typescript-estree@8.56.1': - resolution: {integrity: sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.0.0' - - '@typescript-eslint/utils@8.56.1': - resolution: {integrity: sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.0.0' - - '@typescript-eslint/visitor-keys@8.56.1': - resolution: {integrity: sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@vitejs/plugin-react@5.1.4': - resolution: {integrity: sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA==} - engines: {node: ^20.19.0 || >=22.12.0} - peerDependencies: - vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 - - acorn-jsx@5.3.2: - resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} - peerDependencies: - acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - - acorn@8.16.0: - resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} - engines: {node: '>=0.4.0'} - hasBin: true - - ajv@6.14.0: - resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} - - aria-hidden@1.2.6: - resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} - engines: {node: '>=10'} - - balanced-match@4.0.4: - resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} - engines: {node: 18 || 20 || >=22} - - baseline-browser-mapping@2.10.0: - resolution: {integrity: sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==} - engines: {node: '>=6.0.0'} - hasBin: true - - brace-expansion@5.0.3: - resolution: {integrity: sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==} - engines: {node: 18 || 20 || >=22} - - browserslist@4.28.1: - resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} - engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} - hasBin: true - - caniuse-lite@1.0.30001774: - resolution: {integrity: sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA==} - - class-variance-authority@0.7.1: - resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} - - clsx@2.1.1: - resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} - engines: {node: '>=6'} - - convert-source-map@2.0.0: - resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} - - cross-spawn@7.0.6: - resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} - engines: {node: '>= 8'} - - csstype@3.2.3: - resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} - - debug@4.4.3: - resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - - deep-is@0.1.4: - resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} - - detect-libc@2.1.2: - resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} - engines: {node: '>=8'} - - detect-node-es@1.1.0: - resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} - - electron-to-chromium@1.5.302: - resolution: {integrity: sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==} - - enhanced-resolve@5.19.0: - resolution: {integrity: sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==} - engines: {node: '>=10.13.0'} - - esbuild@0.27.3: - resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} - engines: {node: '>=18'} - hasBin: true - - escalade@3.2.0: - resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} - engines: {node: '>=6'} - - escape-string-regexp@4.0.0: - resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} - engines: {node: '>=10'} - - eslint-plugin-react-hooks@7.0.1: - resolution: {integrity: sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==} - engines: {node: '>=18'} - peerDependencies: - eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 - - eslint-plugin-react-refresh@0.5.2: - resolution: {integrity: sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==} - peerDependencies: - eslint: ^9 || ^10 - - eslint-scope@9.1.1: - resolution: {integrity: sha512-GaUN0sWim5qc8KVErfPBWmc31LEsOkrUJbvJZV+xuL3u2phMUK4HIvXlWAakfC8W4nzlK+chPEAkYOYb5ZScIw==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - eslint-visitor-keys@3.4.3: - resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - - eslint-visitor-keys@5.0.1: - resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - eslint@10.0.2: - resolution: {integrity: sha512-uYixubwmqJZH+KLVYIVKY1JQt7tysXhtj21WSvjcSmU5SVNzMus1bgLe+pAt816yQ8opKfheVVoPLqvVMGejYw==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - hasBin: true - peerDependencies: - jiti: '*' - peerDependenciesMeta: - jiti: - optional: true - - espree@11.1.1: - resolution: {integrity: sha512-AVHPqQoZYc+RUM4/3Ly5udlZY/U4LS8pIG05jEjWM2lQMU/oaZ7qshzAl2YP1tfNmXfftH3ohurfwNAug+MnsQ==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - esquery@1.7.0: - resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} - engines: {node: '>=0.10'} - - esrecurse@4.3.0: - resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} - engines: {node: '>=4.0'} - - estraverse@5.3.0: - resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} - engines: {node: '>=4.0'} - - esutils@2.0.3: - resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} - engines: {node: '>=0.10.0'} - - fast-deep-equal@3.1.3: - resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - - fast-json-stable-stringify@2.1.0: - resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} - - fast-levenshtein@2.0.6: - resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - - fdir@6.5.0: - resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} - engines: {node: '>=12.0.0'} - peerDependencies: - picomatch: ^3 || ^4 - peerDependenciesMeta: - picomatch: - optional: true - - file-entry-cache@8.0.0: - resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} - engines: {node: '>=16.0.0'} - - find-up@5.0.0: - resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} - engines: {node: '>=10'} - - flat-cache@4.0.1: - resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} - engines: {node: '>=16'} - - flatted@3.3.3: - resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} - - framer-motion@12.34.3: - resolution: {integrity: sha512-v81ecyZKYO/DfpTwHivqkxSUBzvceOpoI+wLfgCgoUIKxlFKEXdg0oR9imxwXumT4SFy8vRk9xzJ5l3/Du/55Q==} - peerDependencies: - '@emotion/is-prop-valid': '*' - react: ^18.0.0 || ^19.0.0 - react-dom: ^18.0.0 || ^19.0.0 - peerDependenciesMeta: - '@emotion/is-prop-valid': - optional: true - react: - optional: true - react-dom: - optional: true - - fsevents@2.3.3: - resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - - gensync@1.0.0-beta.2: - resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} - engines: {node: '>=6.9.0'} - - get-nonce@1.0.1: - resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} - engines: {node: '>=6'} - - glob-parent@6.0.2: - resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} - engines: {node: '>=10.13.0'} - - globals@17.3.0: - resolution: {integrity: sha512-yMqGUQVVCkD4tqjOJf3TnrvaaHDMYp4VlUSObbkIiuCPe/ofdMBFIAcBbCSRFWOnos6qRiTVStDwqPLUclaxIw==} - engines: {node: '>=18'} - - graceful-fs@4.2.11: - resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - - hermes-estree@0.25.1: - resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} - - hermes-parser@0.25.1: - resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} - - ignore@5.3.2: - resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} - engines: {node: '>= 4'} - - ignore@7.0.5: - resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} - engines: {node: '>= 4'} - - imurmurhash@0.1.4: - resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} - engines: {node: '>=0.8.19'} - - is-extglob@2.1.1: - resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} - engines: {node: '>=0.10.0'} - - is-glob@4.0.3: - resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} - engines: {node: '>=0.10.0'} - - isexe@2.0.0: - resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - - jiti@2.6.1: - resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} - hasBin: true - - js-tokens@4.0.0: - resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - - jsesc@3.1.0: - resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} - engines: {node: '>=6'} - hasBin: true - - json-buffer@3.0.1: - resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} - - json-schema-traverse@0.4.1: - resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} - - json-stable-stringify-without-jsonify@1.0.1: - resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} - - json5@2.2.3: - resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} - engines: {node: '>=6'} - hasBin: true - - keyv@4.5.4: - resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} - - levn@0.4.1: - resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} - engines: {node: '>= 0.8.0'} - - lightningcss-android-arm64@1.31.1: - resolution: {integrity: sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [android] - - lightningcss-darwin-arm64@1.31.1: - resolution: {integrity: sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [darwin] - - lightningcss-darwin-x64@1.31.1: - resolution: {integrity: sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [darwin] - - lightningcss-freebsd-x64@1.31.1: - resolution: {integrity: sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [freebsd] - - lightningcss-linux-arm-gnueabihf@1.31.1: - resolution: {integrity: sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==} - engines: {node: '>= 12.0.0'} - cpu: [arm] - os: [linux] - - lightningcss-linux-arm64-gnu@1.31.1: - resolution: {integrity: sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [linux] - libc: [glibc] - - lightningcss-linux-arm64-musl@1.31.1: - resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [linux] - libc: [musl] - - lightningcss-linux-x64-gnu@1.31.1: - resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [linux] - libc: [glibc] - - lightningcss-linux-x64-musl@1.31.1: - resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [linux] - libc: [musl] - - lightningcss-win32-arm64-msvc@1.31.1: - resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [win32] - - lightningcss-win32-x64-msvc@1.31.1: - resolution: {integrity: sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [win32] - - lightningcss@1.31.1: - resolution: {integrity: sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==} - engines: {node: '>= 12.0.0'} - - locate-path@6.0.0: - resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} - engines: {node: '>=10'} - - lru-cache@5.1.1: - resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} - - lucide-react@0.575.0: - resolution: {integrity: sha512-VuXgKZrk0uiDlWjGGXmKV6MSk9Yy4l10qgVvzGn2AWBx1Ylt0iBexKOAoA6I7JO3m+M9oeovJd3yYENfkUbOeg==} - peerDependencies: - react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 - - magic-string@0.30.21: - resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - - minimatch@10.2.3: - resolution: {integrity: sha512-Rwi3pnapEqirPSbWbrZaa6N3nmqq4Xer/2XooiOKyV3q12ML06f7MOuc5DVH8ONZIFhwIYQ3yzPH4nt7iWHaTg==} - engines: {node: 18 || 20 || >=22} - - motion-dom@12.34.3: - resolution: {integrity: sha512-sYgFe+pR9aIM7o4fhs2aXtOI+oqlUd33N9Yoxcgo1Fv7M20sRkHtCmzE/VRNIcq7uNJ+qio+Xubt1FXH3pQ+eQ==} - - motion-utils@12.29.2: - resolution: {integrity: sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A==} - - motion@12.34.3: - resolution: {integrity: sha512-xZIkBGO7v/Uvm+EyaqYd+9IpXu0sZqLywVlGdCFrrMiaO9JI4Kx51mO9KlHSWwll+gZUVY5OJsWgYI5FywJ/tw==} - peerDependencies: - '@emotion/is-prop-valid': '*' - react: ^18.0.0 || ^19.0.0 - react-dom: ^18.0.0 || ^19.0.0 - peerDependenciesMeta: - '@emotion/is-prop-valid': - optional: true - react: - optional: true - react-dom: - optional: true - - ms@2.1.3: - resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - - nanoid@3.3.11: - resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - - natural-compare@1.4.0: - resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - - next-themes@0.4.6: - resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==} - peerDependencies: - react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc - react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc - - node-releases@2.0.27: - resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} - - optionator@0.9.4: - resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} - engines: {node: '>= 0.8.0'} - - p-limit@3.1.0: - resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} - engines: {node: '>=10'} - - p-locate@5.0.0: - resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} - engines: {node: '>=10'} - - path-exists@4.0.0: - resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} - engines: {node: '>=8'} - - path-key@3.1.1: - resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} - engines: {node: '>=8'} - - picocolors@1.1.1: - resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - - picomatch@4.0.3: - resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} - engines: {node: '>=12'} - - postcss@8.5.6: - resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} - engines: {node: ^10 || ^12 || >=14} - - prelude-ls@1.2.1: - resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} - engines: {node: '>= 0.8.0'} - - punycode@2.3.1: - resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} - engines: {node: '>=6'} - - radix-ui@1.4.3: - resolution: {integrity: sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - react-dom@19.2.4: - resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} - peerDependencies: - react: ^19.2.4 - - react-refresh@0.18.0: - resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} - engines: {node: '>=0.10.0'} - - react-remove-scroll-bar@2.3.8: - resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} - engines: {node: '>=10'} - peerDependencies: - '@types/react': '*' - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - peerDependenciesMeta: - '@types/react': - optional: true - - react-remove-scroll@2.7.2: - resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} - engines: {node: '>=10'} - peerDependencies: - '@types/react': '*' - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - react-style-singleton@2.2.3: - resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} - engines: {node: '>=10'} - peerDependencies: - '@types/react': '*' - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - react@19.2.4: - resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} - engines: {node: '>=0.10.0'} - - rollup@4.59.0: - resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==} - engines: {node: '>=18.0.0', npm: '>=8.0.0'} - hasBin: true - - scheduler@0.27.0: - resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} - - semver@6.3.1: - resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} - hasBin: true - - semver@7.7.4: - resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} - engines: {node: '>=10'} - hasBin: true - - sharp@0.34.5: - resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - - shebang-command@2.0.0: - resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} - engines: {node: '>=8'} - - shebang-regex@3.0.0: - resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} - engines: {node: '>=8'} - - sonner@2.0.7: - resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==} - peerDependencies: - react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc - react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc - - source-map-js@1.2.1: - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} - engines: {node: '>=0.10.0'} - - tailwind-merge@3.5.0: - resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==} - - tailwindcss@4.2.1: - resolution: {integrity: sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==} - - tapable@2.3.0: - resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} - engines: {node: '>=6'} - - tinyglobby@0.2.15: - resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} - engines: {node: '>=12.0.0'} - - ts-api-utils@2.4.0: - resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==} - engines: {node: '>=18.12'} - peerDependencies: - typescript: '>=4.8.4' - - tslib@2.8.1: - resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - - tw-animate-css@1.4.0: - resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} - - type-check@0.4.0: - resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} - engines: {node: '>= 0.8.0'} - - typescript-eslint@8.56.1: - resolution: {integrity: sha512-U4lM6pjmBX7J5wk4szltF7I1cGBHXZopnAXCMXb3+fZ3B/0Z3hq3wS/CCUB2NZBNAExK92mCU2tEohWuwVMsDQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.0.0' - - typescript@5.9.3: - resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} - engines: {node: '>=14.17'} - hasBin: true - - undici-types@7.18.2: - resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} - - update-browserslist-db@1.2.3: - resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} - hasBin: true - peerDependencies: - browserslist: '>= 4.21.0' - - uri-js@4.4.1: - resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} - - use-callback-ref@1.3.3: - resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} - engines: {node: '>=10'} - peerDependencies: - '@types/react': '*' - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - use-sidecar@1.1.3: - resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} - engines: {node: '>=10'} - peerDependencies: - '@types/react': '*' - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - use-sync-external-store@1.6.0: - resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - - vite@7.3.1: - resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - peerDependencies: - '@types/node': ^20.19.0 || >=22.12.0 - jiti: '>=1.21.0' - less: ^4.0.0 - lightningcss: ^1.21.0 - sass: ^1.70.0 - sass-embedded: ^1.70.0 - stylus: '>=0.54.8' - sugarss: ^5.0.0 - terser: ^5.16.0 - tsx: ^4.8.1 - yaml: ^2.4.2 - peerDependenciesMeta: - '@types/node': - optional: true - jiti: - optional: true - less: - optional: true - lightningcss: - optional: true - sass: - optional: true - sass-embedded: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - tsx: - optional: true - yaml: - optional: true - - which@2.0.2: - resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} - engines: {node: '>= 8'} - hasBin: true - - word-wrap@1.2.5: - resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} - engines: {node: '>=0.10.0'} - - yallist@3.1.1: - resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} - - yocto-queue@0.1.0: - resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} - engines: {node: '>=10'} - - zod-validation-error@4.0.2: - resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} - engines: {node: '>=18.0.0'} - peerDependencies: - zod: ^3.25.0 || ^4.0.0 - - zod@4.3.6: - resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} - -snapshots: - - '@babel/code-frame@7.29.0': - dependencies: - '@babel/helper-validator-identifier': 7.28.5 - js-tokens: 4.0.0 - picocolors: 1.1.1 - - '@babel/compat-data@7.29.0': {} - - '@babel/core@7.29.0': - dependencies: - '@babel/code-frame': 7.29.0 - '@babel/generator': 7.29.1 - '@babel/helper-compilation-targets': 7.28.6 - '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) - '@babel/helpers': 7.28.6 - '@babel/parser': 7.29.0 - '@babel/template': 7.28.6 - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 - '@jridgewell/remapping': 2.3.5 - convert-source-map: 2.0.0 - debug: 4.4.3 - gensync: 1.0.0-beta.2 - json5: 2.2.3 - semver: 6.3.1 - transitivePeerDependencies: - - supports-color - - '@babel/generator@7.29.1': - dependencies: - '@babel/parser': 7.29.0 - '@babel/types': 7.29.0 - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - jsesc: 3.1.0 - - '@babel/helper-compilation-targets@7.28.6': - dependencies: - '@babel/compat-data': 7.29.0 - '@babel/helper-validator-option': 7.27.1 - browserslist: 4.28.1 - lru-cache: 5.1.1 - semver: 6.3.1 - - '@babel/helper-globals@7.28.0': {} - - '@babel/helper-module-imports@7.28.6': - dependencies: - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 - transitivePeerDependencies: - - supports-color - - '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-module-imports': 7.28.6 - '@babel/helper-validator-identifier': 7.28.5 - '@babel/traverse': 7.29.0 - transitivePeerDependencies: - - supports-color - - '@babel/helper-plugin-utils@7.28.6': {} - - '@babel/helper-string-parser@7.27.1': {} - - '@babel/helper-validator-identifier@7.28.5': {} - - '@babel/helper-validator-option@7.27.1': {} - - '@babel/helpers@7.28.6': - dependencies: - '@babel/template': 7.28.6 - '@babel/types': 7.29.0 - - '@babel/parser@7.29.0': - dependencies: - '@babel/types': 7.29.0 - - '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/template@7.28.6': - dependencies: - '@babel/code-frame': 7.29.0 - '@babel/parser': 7.29.0 - '@babel/types': 7.29.0 - - '@babel/traverse@7.29.0': - dependencies: - '@babel/code-frame': 7.29.0 - '@babel/generator': 7.29.1 - '@babel/helper-globals': 7.28.0 - '@babel/parser': 7.29.0 - '@babel/template': 7.28.6 - '@babel/types': 7.29.0 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - - '@babel/types@7.29.0': - dependencies: - '@babel/helper-string-parser': 7.27.1 - '@babel/helper-validator-identifier': 7.28.5 - - '@emnapi/runtime@1.8.1': - dependencies: - tslib: 2.8.1 - optional: true - - '@esbuild/aix-ppc64@0.27.3': - optional: true - - '@esbuild/android-arm64@0.27.3': - optional: true - - '@esbuild/android-arm@0.27.3': - optional: true - - '@esbuild/android-x64@0.27.3': - optional: true - - '@esbuild/darwin-arm64@0.27.3': - optional: true - - '@esbuild/darwin-x64@0.27.3': - optional: true - - '@esbuild/freebsd-arm64@0.27.3': - optional: true - - '@esbuild/freebsd-x64@0.27.3': - optional: true - - '@esbuild/linux-arm64@0.27.3': - optional: true - - '@esbuild/linux-arm@0.27.3': - optional: true - - '@esbuild/linux-ia32@0.27.3': - optional: true - - '@esbuild/linux-loong64@0.27.3': - optional: true - - '@esbuild/linux-mips64el@0.27.3': - optional: true - - '@esbuild/linux-ppc64@0.27.3': - optional: true - - '@esbuild/linux-riscv64@0.27.3': - optional: true - - '@esbuild/linux-s390x@0.27.3': - optional: true - - '@esbuild/linux-x64@0.27.3': - optional: true - - '@esbuild/netbsd-arm64@0.27.3': - optional: true - - '@esbuild/netbsd-x64@0.27.3': - optional: true - - '@esbuild/openbsd-arm64@0.27.3': - optional: true - - '@esbuild/openbsd-x64@0.27.3': - optional: true - - '@esbuild/openharmony-arm64@0.27.3': - optional: true - - '@esbuild/sunos-x64@0.27.3': - optional: true - - '@esbuild/win32-arm64@0.27.3': - optional: true - - '@esbuild/win32-ia32@0.27.3': - optional: true - - '@esbuild/win32-x64@0.27.3': - optional: true - - '@eslint-community/eslint-utils@4.9.1(eslint@10.0.2(jiti@2.6.1))': - dependencies: - eslint: 10.0.2(jiti@2.6.1) - eslint-visitor-keys: 3.4.3 - - '@eslint-community/regexpp@4.12.2': {} - - '@eslint/config-array@0.23.2': - dependencies: - '@eslint/object-schema': 3.0.2 - debug: 4.4.3 - minimatch: 10.2.3 - transitivePeerDependencies: - - supports-color - - '@eslint/config-helpers@0.5.2': - dependencies: - '@eslint/core': 1.1.0 - - '@eslint/core@1.1.0': - dependencies: - '@types/json-schema': 7.0.15 - - '@eslint/js@10.0.1(eslint@10.0.2(jiti@2.6.1))': - optionalDependencies: - eslint: 10.0.2(jiti@2.6.1) - - '@eslint/object-schema@3.0.2': {} - - '@eslint/plugin-kit@0.6.0': - dependencies: - '@eslint/core': 1.1.0 - levn: 0.4.1 - - '@floating-ui/core@1.7.4': - dependencies: - '@floating-ui/utils': 0.2.10 - - '@floating-ui/dom@1.7.5': - dependencies: - '@floating-ui/core': 1.7.4 - '@floating-ui/utils': 0.2.10 - - '@floating-ui/react-dom@2.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@floating-ui/dom': 1.7.5 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - - '@floating-ui/utils@0.2.10': {} - - '@humanfs/core@0.19.1': {} - - '@humanfs/node@0.16.7': - dependencies: - '@humanfs/core': 0.19.1 - '@humanwhocodes/retry': 0.4.3 - - '@humanwhocodes/module-importer@1.0.1': {} - - '@humanwhocodes/retry@0.4.3': {} - - '@img/colour@1.0.0': {} - - '@img/sharp-darwin-arm64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-darwin-arm64': 1.2.4 - optional: true - - '@img/sharp-darwin-x64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-darwin-x64': 1.2.4 - optional: true - - '@img/sharp-libvips-darwin-arm64@1.2.4': - optional: true - - '@img/sharp-libvips-darwin-x64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-arm64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-arm@1.2.4': - optional: true - - '@img/sharp-libvips-linux-ppc64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-riscv64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-s390x@1.2.4': - optional: true - - '@img/sharp-libvips-linux-x64@1.2.4': - optional: true - - '@img/sharp-libvips-linuxmusl-arm64@1.2.4': - optional: true - - '@img/sharp-libvips-linuxmusl-x64@1.2.4': - optional: true - - '@img/sharp-linux-arm64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-arm64': 1.2.4 - optional: true - - '@img/sharp-linux-arm@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.2.4 - optional: true - - '@img/sharp-linux-ppc64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-ppc64': 1.2.4 - optional: true - - '@img/sharp-linux-riscv64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-riscv64': 1.2.4 - optional: true - - '@img/sharp-linux-s390x@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-s390x': 1.2.4 - optional: true - - '@img/sharp-linux-x64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.2.4 - optional: true - - '@img/sharp-linuxmusl-arm64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 - optional: true - - '@img/sharp-linuxmusl-x64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.2.4 - optional: true - - '@img/sharp-wasm32@0.34.5': - dependencies: - '@emnapi/runtime': 1.8.1 - optional: true - - '@img/sharp-win32-arm64@0.34.5': - optional: true - - '@img/sharp-win32-ia32@0.34.5': - optional: true - - '@img/sharp-win32-x64@0.34.5': - optional: true - - '@jridgewell/gen-mapping@0.3.13': - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - '@jridgewell/trace-mapping': 0.3.31 - - '@jridgewell/remapping@2.3.5': - dependencies: - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - - '@jridgewell/resolve-uri@3.1.2': {} - - '@jridgewell/sourcemap-codec@1.5.5': {} - - '@jridgewell/trace-mapping@0.3.31': - dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 - - '@radix-ui/number@1.1.1': {} - - '@radix-ui/primitive@1.1.3': {} - - '@radix-ui/react-accessible-icon@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-accordion@1.2.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-alert-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-aspect-ratio@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-avatar@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-checkbox@1.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-collapsible@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.14)(react@19.2.4)': - dependencies: - react: 19.2.4 - optionalDependencies: - '@types/react': 19.2.14 - - '@radix-ui/react-context-menu@2.2.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-context@1.1.2(@types/react@19.2.14)(react@19.2.4)': - dependencies: - react: 19.2.4 - optionalDependencies: - '@types/react': 19.2.14 - - '@radix-ui/react-context@1.1.3(@types/react@19.2.14)(react@19.2.4)': - dependencies: - react: 19.2.4 - optionalDependencies: - '@types/react': 19.2.14 - - '@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - aria-hidden: 1.2.6 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-direction@1.1.1(@types/react@19.2.14)(react@19.2.4)': - dependencies: - react: 19.2.4 - optionalDependencies: - '@types/react': 19.2.14 - - '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.14)(react@19.2.4)': - dependencies: - react: 19.2.4 - optionalDependencies: - '@types/react': 19.2.14 - - '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-form@0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-label': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-hover-card@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-id@1.1.1(@types/react@19.2.14)(react@19.2.4)': - dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - optionalDependencies: - '@types/react': 19.2.14 - - '@radix-ui/react-label@2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-label@2.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) - aria-hidden: 1.2.6 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-menubar@1.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-navigation-menu@1.2.14(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-one-time-password-field@0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/number': 1.1.1 - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-password-toggle-field@0.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - aria-hidden: 1.2.6 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@floating-ui/react-dom': 2.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-rect': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/rect': 1.1.1 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-primitive@2.1.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/react-slot': 1.2.4(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-progress@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-progress@1.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/react-context': 1.1.3(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-radio-group@1.3.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-scroll-area@1.2.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/number': 1.1.1 - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-select@2.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/number': 1.1.1 - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - aria-hidden: 1.2.6 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-separator@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-slider@1.3.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/number': 1.1.1 - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-slot@1.2.3(@types/react@19.2.14)(react@19.2.4)': - dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - optionalDependencies: - '@types/react': 19.2.14 - - '@radix-ui/react-slot@1.2.4(@types/react@19.2.14)(react@19.2.4)': - dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - optionalDependencies: - '@types/react': 19.2.14 - - '@radix-ui/react-switch@1.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-tabs@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-toast@1.2.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-toggle-group@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-toggle': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-toggle@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-toolbar@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-separator': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-toggle-group': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-tooltip@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.14)(react@19.2.4)': - dependencies: - react: 19.2.4 - optionalDependencies: - '@types/react': 19.2.14 - - '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.14)(react@19.2.4)': - dependencies: - '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - optionalDependencies: - '@types/react': 19.2.14 - - '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.14)(react@19.2.4)': - dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - optionalDependencies: - '@types/react': 19.2.14 - - '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.14)(react@19.2.4)': - dependencies: - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - optionalDependencies: - '@types/react': 19.2.14 - - '@radix-ui/react-use-is-hydrated@0.1.0(@types/react@19.2.14)(react@19.2.4)': - dependencies: - react: 19.2.4 - use-sync-external-store: 1.6.0(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - - '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.14)(react@19.2.4)': - dependencies: - react: 19.2.4 - optionalDependencies: - '@types/react': 19.2.14 - - '@radix-ui/react-use-previous@1.1.1(@types/react@19.2.14)(react@19.2.4)': - dependencies: - react: 19.2.4 - optionalDependencies: - '@types/react': 19.2.14 - - '@radix-ui/react-use-rect@1.1.1(@types/react@19.2.14)(react@19.2.4)': - dependencies: - '@radix-ui/rect': 1.1.1 - react: 19.2.4 - optionalDependencies: - '@types/react': 19.2.14 - - '@radix-ui/react-use-size@1.1.1(@types/react@19.2.14)(react@19.2.4)': - dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) - react: 19.2.4 - optionalDependencies: - '@types/react': 19.2.14 - - '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@radix-ui/rect@1.1.1': {} - - '@rolldown/pluginutils@1.0.0-rc.3': {} - - '@rollup/rollup-android-arm-eabi@4.59.0': - optional: true - - '@rollup/rollup-android-arm64@4.59.0': - optional: true - - '@rollup/rollup-darwin-arm64@4.59.0': - optional: true - - '@rollup/rollup-darwin-x64@4.59.0': - optional: true - - '@rollup/rollup-freebsd-arm64@4.59.0': - optional: true - - '@rollup/rollup-freebsd-x64@4.59.0': - optional: true - - '@rollup/rollup-linux-arm-gnueabihf@4.59.0': - optional: true - - '@rollup/rollup-linux-arm-musleabihf@4.59.0': - optional: true - - '@rollup/rollup-linux-arm64-gnu@4.59.0': - optional: true - - '@rollup/rollup-linux-arm64-musl@4.59.0': - optional: true - - '@rollup/rollup-linux-loong64-gnu@4.59.0': - optional: true - - '@rollup/rollup-linux-loong64-musl@4.59.0': - optional: true - - '@rollup/rollup-linux-ppc64-gnu@4.59.0': - optional: true - - '@rollup/rollup-linux-ppc64-musl@4.59.0': - optional: true - - '@rollup/rollup-linux-riscv64-gnu@4.59.0': - optional: true - - '@rollup/rollup-linux-riscv64-musl@4.59.0': - optional: true - - '@rollup/rollup-linux-s390x-gnu@4.59.0': - optional: true - - '@rollup/rollup-linux-x64-gnu@4.59.0': - optional: true - - '@rollup/rollup-linux-x64-musl@4.59.0': - optional: true - - '@rollup/rollup-openbsd-x64@4.59.0': - optional: true - - '@rollup/rollup-openharmony-arm64@4.59.0': - optional: true - - '@rollup/rollup-win32-arm64-msvc@4.59.0': - optional: true - - '@rollup/rollup-win32-ia32-msvc@4.59.0': - optional: true - - '@rollup/rollup-win32-x64-gnu@4.59.0': - optional: true - - '@rollup/rollup-win32-x64-msvc@4.59.0': - optional: true - - '@tailwindcss/node@4.2.1': - dependencies: - '@jridgewell/remapping': 2.3.5 - enhanced-resolve: 5.19.0 - jiti: 2.6.1 - lightningcss: 1.31.1 - magic-string: 0.30.21 - source-map-js: 1.2.1 - tailwindcss: 4.2.1 - - '@tailwindcss/oxide-android-arm64@4.2.1': - optional: true - - '@tailwindcss/oxide-darwin-arm64@4.2.1': - optional: true - - '@tailwindcss/oxide-darwin-x64@4.2.1': - optional: true - - '@tailwindcss/oxide-freebsd-x64@4.2.1': - optional: true - - '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1': - optional: true - - '@tailwindcss/oxide-linux-arm64-gnu@4.2.1': - optional: true - - '@tailwindcss/oxide-linux-arm64-musl@4.2.1': - optional: true - - '@tailwindcss/oxide-linux-x64-gnu@4.2.1': - optional: true - - '@tailwindcss/oxide-linux-x64-musl@4.2.1': - optional: true - - '@tailwindcss/oxide-wasm32-wasi@4.2.1': - optional: true - - '@tailwindcss/oxide-win32-arm64-msvc@4.2.1': - optional: true - - '@tailwindcss/oxide-win32-x64-msvc@4.2.1': - optional: true - - '@tailwindcss/oxide@4.2.1': - optionalDependencies: - '@tailwindcss/oxide-android-arm64': 4.2.1 - '@tailwindcss/oxide-darwin-arm64': 4.2.1 - '@tailwindcss/oxide-darwin-x64': 4.2.1 - '@tailwindcss/oxide-freebsd-x64': 4.2.1 - '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.1 - '@tailwindcss/oxide-linux-arm64-gnu': 4.2.1 - '@tailwindcss/oxide-linux-arm64-musl': 4.2.1 - '@tailwindcss/oxide-linux-x64-gnu': 4.2.1 - '@tailwindcss/oxide-linux-x64-musl': 4.2.1 - '@tailwindcss/oxide-wasm32-wasi': 4.2.1 - '@tailwindcss/oxide-win32-arm64-msvc': 4.2.1 - '@tailwindcss/oxide-win32-x64-msvc': 4.2.1 - - '@tailwindcss/vite@4.2.1(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1))': - dependencies: - '@tailwindcss/node': 4.2.1 - '@tailwindcss/oxide': 4.2.1 - tailwindcss: 4.2.1 - vite: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1) - - '@types/babel__core@7.20.5': - dependencies: - '@babel/parser': 7.29.0 - '@babel/types': 7.29.0 - '@types/babel__generator': 7.27.0 - '@types/babel__template': 7.4.4 - '@types/babel__traverse': 7.28.0 - - '@types/babel__generator@7.27.0': - dependencies: - '@babel/types': 7.29.0 - - '@types/babel__template@7.4.4': - dependencies: - '@babel/parser': 7.29.0 - '@babel/types': 7.29.0 - - '@types/babel__traverse@7.28.0': - dependencies: - '@babel/types': 7.29.0 - - '@types/esrecurse@4.3.1': {} - - '@types/estree@1.0.8': {} - - '@types/json-schema@7.0.15': {} - - '@types/node@25.3.0': - dependencies: - undici-types: 7.18.2 - - '@types/react-dom@19.2.3(@types/react@19.2.14)': - dependencies: - '@types/react': 19.2.14 - - '@types/react@19.2.14': - dependencies: - csstype: 3.2.3 - - '@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)': - dependencies: - '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.56.1 - '@typescript-eslint/type-utils': 8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/utils': 8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.56.1 - eslint: 10.0.2(jiti@2.6.1) - ignore: 7.0.5 - natural-compare: 1.4.0 - ts-api-utils: 2.4.0(typescript@5.9.3) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/parser@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)': - dependencies: - '@typescript-eslint/scope-manager': 8.56.1 - '@typescript-eslint/types': 8.56.1 - '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.56.1 - debug: 4.4.3 - eslint: 10.0.2(jiti@2.6.1) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/project-service@8.56.1(typescript@5.9.3)': - dependencies: - '@typescript-eslint/tsconfig-utils': 8.56.1(typescript@5.9.3) - '@typescript-eslint/types': 8.56.1 - debug: 4.4.3 - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/scope-manager@8.56.1': - dependencies: - '@typescript-eslint/types': 8.56.1 - '@typescript-eslint/visitor-keys': 8.56.1 - - '@typescript-eslint/tsconfig-utils@8.56.1(typescript@5.9.3)': - dependencies: - typescript: 5.9.3 - - '@typescript-eslint/type-utils@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)': - dependencies: - '@typescript-eslint/types': 8.56.1 - '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) - '@typescript-eslint/utils': 8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) - debug: 4.4.3 - eslint: 10.0.2(jiti@2.6.1) - ts-api-utils: 2.4.0(typescript@5.9.3) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/types@8.56.1': {} - - '@typescript-eslint/typescript-estree@8.56.1(typescript@5.9.3)': - dependencies: - '@typescript-eslint/project-service': 8.56.1(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.56.1(typescript@5.9.3) - '@typescript-eslint/types': 8.56.1 - '@typescript-eslint/visitor-keys': 8.56.1 - debug: 4.4.3 - minimatch: 10.2.3 - semver: 7.7.4 - tinyglobby: 0.2.15 - ts-api-utils: 2.4.0(typescript@5.9.3) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/utils@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)': - dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.0.2(jiti@2.6.1)) - '@typescript-eslint/scope-manager': 8.56.1 - '@typescript-eslint/types': 8.56.1 - '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) - eslint: 10.0.2(jiti@2.6.1) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/visitor-keys@8.56.1': - dependencies: - '@typescript-eslint/types': 8.56.1 - eslint-visitor-keys: 5.0.1 - - '@vitejs/plugin-react@5.1.4(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1))': - dependencies: - '@babel/core': 7.29.0 - '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) - '@rolldown/pluginutils': 1.0.0-rc.3 - '@types/babel__core': 7.20.5 - react-refresh: 0.18.0 - vite: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1) - transitivePeerDependencies: - - supports-color - - acorn-jsx@5.3.2(acorn@8.16.0): - dependencies: - acorn: 8.16.0 - - acorn@8.16.0: {} - - ajv@6.14.0: - dependencies: - fast-deep-equal: 3.1.3 - fast-json-stable-stringify: 2.1.0 - json-schema-traverse: 0.4.1 - uri-js: 4.4.1 - - aria-hidden@1.2.6: - dependencies: - tslib: 2.8.1 - - balanced-match@4.0.4: {} - - baseline-browser-mapping@2.10.0: {} - - brace-expansion@5.0.3: - dependencies: - balanced-match: 4.0.4 - - browserslist@4.28.1: - dependencies: - baseline-browser-mapping: 2.10.0 - caniuse-lite: 1.0.30001774 - electron-to-chromium: 1.5.302 - node-releases: 2.0.27 - update-browserslist-db: 1.2.3(browserslist@4.28.1) - - caniuse-lite@1.0.30001774: {} - - class-variance-authority@0.7.1: - dependencies: - clsx: 2.1.1 - - clsx@2.1.1: {} - - convert-source-map@2.0.0: {} - - cross-spawn@7.0.6: - dependencies: - path-key: 3.1.1 - shebang-command: 2.0.0 - which: 2.0.2 - - csstype@3.2.3: {} - - debug@4.4.3: - dependencies: - ms: 2.1.3 - - deep-is@0.1.4: {} - - detect-libc@2.1.2: {} - - detect-node-es@1.1.0: {} - - electron-to-chromium@1.5.302: {} - - enhanced-resolve@5.19.0: - dependencies: - graceful-fs: 4.2.11 - tapable: 2.3.0 - - esbuild@0.27.3: - optionalDependencies: - '@esbuild/aix-ppc64': 0.27.3 - '@esbuild/android-arm': 0.27.3 - '@esbuild/android-arm64': 0.27.3 - '@esbuild/android-x64': 0.27.3 - '@esbuild/darwin-arm64': 0.27.3 - '@esbuild/darwin-x64': 0.27.3 - '@esbuild/freebsd-arm64': 0.27.3 - '@esbuild/freebsd-x64': 0.27.3 - '@esbuild/linux-arm': 0.27.3 - '@esbuild/linux-arm64': 0.27.3 - '@esbuild/linux-ia32': 0.27.3 - '@esbuild/linux-loong64': 0.27.3 - '@esbuild/linux-mips64el': 0.27.3 - '@esbuild/linux-ppc64': 0.27.3 - '@esbuild/linux-riscv64': 0.27.3 - '@esbuild/linux-s390x': 0.27.3 - '@esbuild/linux-x64': 0.27.3 - '@esbuild/netbsd-arm64': 0.27.3 - '@esbuild/netbsd-x64': 0.27.3 - '@esbuild/openbsd-arm64': 0.27.3 - '@esbuild/openbsd-x64': 0.27.3 - '@esbuild/openharmony-arm64': 0.27.3 - '@esbuild/sunos-x64': 0.27.3 - '@esbuild/win32-arm64': 0.27.3 - '@esbuild/win32-ia32': 0.27.3 - '@esbuild/win32-x64': 0.27.3 - - escalade@3.2.0: {} - - escape-string-regexp@4.0.0: {} - - eslint-plugin-react-hooks@7.0.1(eslint@10.0.2(jiti@2.6.1)): - dependencies: - '@babel/core': 7.29.0 - '@babel/parser': 7.29.0 - eslint: 10.0.2(jiti@2.6.1) - hermes-parser: 0.25.1 - zod: 4.3.6 - zod-validation-error: 4.0.2(zod@4.3.6) - transitivePeerDependencies: - - supports-color - - eslint-plugin-react-refresh@0.5.2(eslint@10.0.2(jiti@2.6.1)): - dependencies: - eslint: 10.0.2(jiti@2.6.1) - - eslint-scope@9.1.1: - dependencies: - '@types/esrecurse': 4.3.1 - '@types/estree': 1.0.8 - esrecurse: 4.3.0 - estraverse: 5.3.0 - - eslint-visitor-keys@3.4.3: {} - - eslint-visitor-keys@5.0.1: {} - - eslint@10.0.2(jiti@2.6.1): - dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.0.2(jiti@2.6.1)) - '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.23.2 - '@eslint/config-helpers': 0.5.2 - '@eslint/core': 1.1.0 - '@eslint/plugin-kit': 0.6.0 - '@humanfs/node': 0.16.7 - '@humanwhocodes/module-importer': 1.0.1 - '@humanwhocodes/retry': 0.4.3 - '@types/estree': 1.0.8 - ajv: 6.14.0 - cross-spawn: 7.0.6 - debug: 4.4.3 - escape-string-regexp: 4.0.0 - eslint-scope: 9.1.1 - eslint-visitor-keys: 5.0.1 - espree: 11.1.1 - esquery: 1.7.0 - esutils: 2.0.3 - fast-deep-equal: 3.1.3 - file-entry-cache: 8.0.0 - find-up: 5.0.0 - glob-parent: 6.0.2 - ignore: 5.3.2 - imurmurhash: 0.1.4 - is-glob: 4.0.3 - json-stable-stringify-without-jsonify: 1.0.1 - minimatch: 10.2.3 - natural-compare: 1.4.0 - optionator: 0.9.4 - optionalDependencies: - jiti: 2.6.1 - transitivePeerDependencies: - - supports-color - - espree@11.1.1: - dependencies: - acorn: 8.16.0 - acorn-jsx: 5.3.2(acorn@8.16.0) - eslint-visitor-keys: 5.0.1 - - esquery@1.7.0: - dependencies: - estraverse: 5.3.0 - - esrecurse@4.3.0: - dependencies: - estraverse: 5.3.0 - - estraverse@5.3.0: {} - - esutils@2.0.3: {} - - fast-deep-equal@3.1.3: {} - - fast-json-stable-stringify@2.1.0: {} - - fast-levenshtein@2.0.6: {} - - fdir@6.5.0(picomatch@4.0.3): - optionalDependencies: - picomatch: 4.0.3 - - file-entry-cache@8.0.0: - dependencies: - flat-cache: 4.0.1 - - find-up@5.0.0: - dependencies: - locate-path: 6.0.0 - path-exists: 4.0.0 - - flat-cache@4.0.1: - dependencies: - flatted: 3.3.3 - keyv: 4.5.4 - - flatted@3.3.3: {} - - framer-motion@12.34.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4): - dependencies: - motion-dom: 12.34.3 - motion-utils: 12.29.2 - tslib: 2.8.1 - optionalDependencies: - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - - fsevents@2.3.3: - optional: true - - gensync@1.0.0-beta.2: {} - - get-nonce@1.0.1: {} - - glob-parent@6.0.2: - dependencies: - is-glob: 4.0.3 - - globals@17.3.0: {} - - graceful-fs@4.2.11: {} - - hermes-estree@0.25.1: {} - - hermes-parser@0.25.1: - dependencies: - hermes-estree: 0.25.1 - - ignore@5.3.2: {} - - ignore@7.0.5: {} - - imurmurhash@0.1.4: {} - - is-extglob@2.1.1: {} - - is-glob@4.0.3: - dependencies: - is-extglob: 2.1.1 - - isexe@2.0.0: {} - - jiti@2.6.1: {} - - js-tokens@4.0.0: {} - - jsesc@3.1.0: {} - - json-buffer@3.0.1: {} - - json-schema-traverse@0.4.1: {} - - json-stable-stringify-without-jsonify@1.0.1: {} - - json5@2.2.3: {} - - keyv@4.5.4: - dependencies: - json-buffer: 3.0.1 - - levn@0.4.1: - dependencies: - prelude-ls: 1.2.1 - type-check: 0.4.0 - - lightningcss-android-arm64@1.31.1: - optional: true - - lightningcss-darwin-arm64@1.31.1: - optional: true - - lightningcss-darwin-x64@1.31.1: - optional: true - - lightningcss-freebsd-x64@1.31.1: - optional: true - - lightningcss-linux-arm-gnueabihf@1.31.1: - optional: true - - lightningcss-linux-arm64-gnu@1.31.1: - optional: true - - lightningcss-linux-arm64-musl@1.31.1: - optional: true - - lightningcss-linux-x64-gnu@1.31.1: - optional: true - - lightningcss-linux-x64-musl@1.31.1: - optional: true - - lightningcss-win32-arm64-msvc@1.31.1: - optional: true - - lightningcss-win32-x64-msvc@1.31.1: - optional: true - - lightningcss@1.31.1: - dependencies: - detect-libc: 2.1.2 - optionalDependencies: - lightningcss-android-arm64: 1.31.1 - lightningcss-darwin-arm64: 1.31.1 - lightningcss-darwin-x64: 1.31.1 - lightningcss-freebsd-x64: 1.31.1 - lightningcss-linux-arm-gnueabihf: 1.31.1 - lightningcss-linux-arm64-gnu: 1.31.1 - lightningcss-linux-arm64-musl: 1.31.1 - lightningcss-linux-x64-gnu: 1.31.1 - lightningcss-linux-x64-musl: 1.31.1 - lightningcss-win32-arm64-msvc: 1.31.1 - lightningcss-win32-x64-msvc: 1.31.1 - - locate-path@6.0.0: - dependencies: - p-locate: 5.0.0 - - lru-cache@5.1.1: - dependencies: - yallist: 3.1.1 - - lucide-react@0.575.0(react@19.2.4): - dependencies: - react: 19.2.4 - - magic-string@0.30.21: - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - - minimatch@10.2.3: - dependencies: - brace-expansion: 5.0.3 - - motion-dom@12.34.3: - dependencies: - motion-utils: 12.29.2 - - motion-utils@12.29.2: {} - - motion@12.34.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4): - dependencies: - framer-motion: 12.34.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - tslib: 2.8.1 - optionalDependencies: - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - - ms@2.1.3: {} - - nanoid@3.3.11: {} - - natural-compare@1.4.0: {} - - next-themes@0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4): - dependencies: - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - - node-releases@2.0.27: {} - - optionator@0.9.4: - dependencies: - deep-is: 0.1.4 - fast-levenshtein: 2.0.6 - levn: 0.4.1 - prelude-ls: 1.2.1 - type-check: 0.4.0 - word-wrap: 1.2.5 - - p-limit@3.1.0: - dependencies: - yocto-queue: 0.1.0 - - p-locate@5.0.0: - dependencies: - p-limit: 3.1.0 - - path-exists@4.0.0: {} - - path-key@3.1.1: {} - - picocolors@1.1.1: {} - - picomatch@4.0.3: {} - - postcss@8.5.6: - dependencies: - nanoid: 3.3.11 - picocolors: 1.1.1 - source-map-js: 1.2.1 - - prelude-ls@1.2.1: {} - - punycode@2.3.1: {} - - radix-ui@1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-accessible-icon': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-accordion': 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-alert-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-aspect-ratio': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-avatar': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-checkbox': 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context-menu': 2.2.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-dropdown-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-form': 0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-hover-card': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-label': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-menubar': 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-navigation-menu': 1.2.14(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-one-time-password-field': 0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-password-toggle-field': 0.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-popover': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-progress': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-radio-group': 1.3.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-scroll-area': 1.2.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-select': 2.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-separator': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-slider': 1.3.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-switch': 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-toast': 1.2.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-toggle': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-toggle-group': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-toolbar': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-tooltip': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - react-dom@19.2.4(react@19.2.4): - dependencies: - react: 19.2.4 - scheduler: 0.27.0 - - react-refresh@0.18.0: {} - - react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.4): - dependencies: - react: 19.2.4 - react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.4) - tslib: 2.8.1 - optionalDependencies: - '@types/react': 19.2.14 - - react-remove-scroll@2.7.2(@types/react@19.2.14)(react@19.2.4): - dependencies: - react: 19.2.4 - react-remove-scroll-bar: 2.3.8(@types/react@19.2.14)(react@19.2.4) - react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.4) - tslib: 2.8.1 - use-callback-ref: 1.3.3(@types/react@19.2.14)(react@19.2.4) - use-sidecar: 1.1.3(@types/react@19.2.14)(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - - react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.4): - dependencies: - get-nonce: 1.0.1 - react: 19.2.4 - tslib: 2.8.1 - optionalDependencies: - '@types/react': 19.2.14 - - react@19.2.4: {} - - rollup@4.59.0: - dependencies: - '@types/estree': 1.0.8 - optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.59.0 - '@rollup/rollup-android-arm64': 4.59.0 - '@rollup/rollup-darwin-arm64': 4.59.0 - '@rollup/rollup-darwin-x64': 4.59.0 - '@rollup/rollup-freebsd-arm64': 4.59.0 - '@rollup/rollup-freebsd-x64': 4.59.0 - '@rollup/rollup-linux-arm-gnueabihf': 4.59.0 - '@rollup/rollup-linux-arm-musleabihf': 4.59.0 - '@rollup/rollup-linux-arm64-gnu': 4.59.0 - '@rollup/rollup-linux-arm64-musl': 4.59.0 - '@rollup/rollup-linux-loong64-gnu': 4.59.0 - '@rollup/rollup-linux-loong64-musl': 4.59.0 - '@rollup/rollup-linux-ppc64-gnu': 4.59.0 - '@rollup/rollup-linux-ppc64-musl': 4.59.0 - '@rollup/rollup-linux-riscv64-gnu': 4.59.0 - '@rollup/rollup-linux-riscv64-musl': 4.59.0 - '@rollup/rollup-linux-s390x-gnu': 4.59.0 - '@rollup/rollup-linux-x64-gnu': 4.59.0 - '@rollup/rollup-linux-x64-musl': 4.59.0 - '@rollup/rollup-openbsd-x64': 4.59.0 - '@rollup/rollup-openharmony-arm64': 4.59.0 - '@rollup/rollup-win32-arm64-msvc': 4.59.0 - '@rollup/rollup-win32-ia32-msvc': 4.59.0 - '@rollup/rollup-win32-x64-gnu': 4.59.0 - '@rollup/rollup-win32-x64-msvc': 4.59.0 - fsevents: 2.3.3 - - scheduler@0.27.0: {} - - semver@6.3.1: {} - - semver@7.7.4: {} - - sharp@0.34.5: - dependencies: - '@img/colour': 1.0.0 - detect-libc: 2.1.2 - semver: 7.7.4 - optionalDependencies: - '@img/sharp-darwin-arm64': 0.34.5 - '@img/sharp-darwin-x64': 0.34.5 - '@img/sharp-libvips-darwin-arm64': 1.2.4 - '@img/sharp-libvips-darwin-x64': 1.2.4 - '@img/sharp-libvips-linux-arm': 1.2.4 - '@img/sharp-libvips-linux-arm64': 1.2.4 - '@img/sharp-libvips-linux-ppc64': 1.2.4 - '@img/sharp-libvips-linux-riscv64': 1.2.4 - '@img/sharp-libvips-linux-s390x': 1.2.4 - '@img/sharp-libvips-linux-x64': 1.2.4 - '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 - '@img/sharp-libvips-linuxmusl-x64': 1.2.4 - '@img/sharp-linux-arm': 0.34.5 - '@img/sharp-linux-arm64': 0.34.5 - '@img/sharp-linux-ppc64': 0.34.5 - '@img/sharp-linux-riscv64': 0.34.5 - '@img/sharp-linux-s390x': 0.34.5 - '@img/sharp-linux-x64': 0.34.5 - '@img/sharp-linuxmusl-arm64': 0.34.5 - '@img/sharp-linuxmusl-x64': 0.34.5 - '@img/sharp-wasm32': 0.34.5 - '@img/sharp-win32-arm64': 0.34.5 - '@img/sharp-win32-ia32': 0.34.5 - '@img/sharp-win32-x64': 0.34.5 - - shebang-command@2.0.0: - dependencies: - shebang-regex: 3.0.0 - - shebang-regex@3.0.0: {} - - sonner@2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4): - dependencies: - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - - source-map-js@1.2.1: {} - - tailwind-merge@3.5.0: {} - - tailwindcss@4.2.1: {} - - tapable@2.3.0: {} - - tinyglobby@0.2.15: - dependencies: - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 - - ts-api-utils@2.4.0(typescript@5.9.3): - dependencies: - typescript: 5.9.3 - - tslib@2.8.1: {} - - tw-animate-css@1.4.0: {} - - type-check@0.4.0: - dependencies: - prelude-ls: 1.2.1 - - typescript-eslint@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3): - dependencies: - '@typescript-eslint/eslint-plugin': 8.56.1(@typescript-eslint/parser@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/parser': 8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) - '@typescript-eslint/utils': 8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) - eslint: 10.0.2(jiti@2.6.1) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - typescript@5.9.3: {} - - undici-types@7.18.2: {} - - update-browserslist-db@1.2.3(browserslist@4.28.1): - dependencies: - browserslist: 4.28.1 - escalade: 3.2.0 - picocolors: 1.1.1 - - uri-js@4.4.1: - dependencies: - punycode: 2.3.1 - - use-callback-ref@1.3.3(@types/react@19.2.14)(react@19.2.4): - dependencies: - react: 19.2.4 - tslib: 2.8.1 - optionalDependencies: - '@types/react': 19.2.14 - - use-sidecar@1.1.3(@types/react@19.2.14)(react@19.2.4): - dependencies: - detect-node-es: 1.1.0 - react: 19.2.4 - tslib: 2.8.1 - optionalDependencies: - '@types/react': 19.2.14 - - use-sync-external-store@1.6.0(react@19.2.4): - dependencies: - react: 19.2.4 - - vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1): - dependencies: - esbuild: 0.27.3 - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 - postcss: 8.5.6 - rollup: 4.59.0 - tinyglobby: 0.2.15 - optionalDependencies: - '@types/node': 25.3.0 - fsevents: 2.3.3 - jiti: 2.6.1 - lightningcss: 1.31.1 - - which@2.0.2: - dependencies: - isexe: 2.0.0 - - word-wrap@1.2.5: {} - - yallist@3.1.1: {} - - yocto-queue@0.1.0: {} - - zod-validation-error@4.0.2(zod@4.3.6): - dependencies: - zod: 4.3.6 - - zod@4.3.6: {} diff --git a/frontend/public/assets/flags/ad.svg b/frontend/public/assets/flags/ad.svg deleted file mode 100644 index 199ff19..0000000 --- a/frontend/public/assets/flags/ad.svg +++ /dev/null @@ -1,150 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/ae.svg b/frontend/public/assets/flags/ae.svg deleted file mode 100644 index 651ac85..0000000 --- a/frontend/public/assets/flags/ae.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/frontend/public/assets/flags/af.svg b/frontend/public/assets/flags/af.svg deleted file mode 100644 index 4dbe455..0000000 --- a/frontend/public/assets/flags/af.svg +++ /dev/null @@ -1,81 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/ag.svg b/frontend/public/assets/flags/ag.svg deleted file mode 100644 index 243c3d8..0000000 --- a/frontend/public/assets/flags/ag.svg +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/ai.svg b/frontend/public/assets/flags/ai.svg deleted file mode 100644 index 9c2ea33..0000000 --- a/frontend/public/assets/flags/ai.svg +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/al.svg b/frontend/public/assets/flags/al.svg deleted file mode 100644 index e85d95f..0000000 --- a/frontend/public/assets/flags/al.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/frontend/public/assets/flags/am.svg b/frontend/public/assets/flags/am.svg deleted file mode 100644 index 99fa4dc..0000000 --- a/frontend/public/assets/flags/am.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/frontend/public/assets/flags/ao.svg b/frontend/public/assets/flags/ao.svg deleted file mode 100644 index b73b1ec..0000000 --- a/frontend/public/assets/flags/ao.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/aq.svg b/frontend/public/assets/flags/aq.svg deleted file mode 100644 index c7e3536..0000000 --- a/frontend/public/assets/flags/aq.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/frontend/public/assets/flags/ar.svg b/frontend/public/assets/flags/ar.svg deleted file mode 100644 index c753da1..0000000 --- a/frontend/public/assets/flags/ar.svg +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/arab.svg b/frontend/public/assets/flags/arab.svg deleted file mode 100644 index 9ef079f..0000000 --- a/frontend/public/assets/flags/arab.svg +++ /dev/null @@ -1,109 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/as.svg b/frontend/public/assets/flags/as.svg deleted file mode 100644 index 82459de..0000000 --- a/frontend/public/assets/flags/as.svg +++ /dev/null @@ -1,72 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/asean.svg b/frontend/public/assets/flags/asean.svg deleted file mode 100644 index 189ae02..0000000 --- a/frontend/public/assets/flags/asean.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/at.svg b/frontend/public/assets/flags/at.svg deleted file mode 100644 index 9d2775c..0000000 --- a/frontend/public/assets/flags/at.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/public/assets/flags/au.svg b/frontend/public/assets/flags/au.svg deleted file mode 100644 index 96e8076..0000000 --- a/frontend/public/assets/flags/au.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/frontend/public/assets/flags/aw.svg b/frontend/public/assets/flags/aw.svg deleted file mode 100644 index 413b7c4..0000000 --- a/frontend/public/assets/flags/aw.svg +++ /dev/null @@ -1,186 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/ax.svg b/frontend/public/assets/flags/ax.svg deleted file mode 100644 index 0584d71..0000000 --- a/frontend/public/assets/flags/ax.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/az.svg b/frontend/public/assets/flags/az.svg deleted file mode 100644 index 3557522..0000000 --- a/frontend/public/assets/flags/az.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/frontend/public/assets/flags/ba.svg b/frontend/public/assets/flags/ba.svg deleted file mode 100644 index 93bd9cf..0000000 --- a/frontend/public/assets/flags/ba.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/bb.svg b/frontend/public/assets/flags/bb.svg deleted file mode 100644 index cecd5cc..0000000 --- a/frontend/public/assets/flags/bb.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/frontend/public/assets/flags/bd.svg b/frontend/public/assets/flags/bd.svg deleted file mode 100644 index 16b794d..0000000 --- a/frontend/public/assets/flags/bd.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/public/assets/flags/be.svg b/frontend/public/assets/flags/be.svg deleted file mode 100644 index ac706a0..0000000 --- a/frontend/public/assets/flags/be.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/frontend/public/assets/flags/bf.svg b/frontend/public/assets/flags/bf.svg deleted file mode 100644 index 4713822..0000000 --- a/frontend/public/assets/flags/bf.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/frontend/public/assets/flags/bg.svg b/frontend/public/assets/flags/bg.svg deleted file mode 100644 index af2d0d0..0000000 --- a/frontend/public/assets/flags/bg.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/frontend/public/assets/flags/bh.svg b/frontend/public/assets/flags/bh.svg deleted file mode 100644 index 7a2ea54..0000000 --- a/frontend/public/assets/flags/bh.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/public/assets/flags/bi.svg b/frontend/public/assets/flags/bi.svg deleted file mode 100644 index a4434a9..0000000 --- a/frontend/public/assets/flags/bi.svg +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/bj.svg b/frontend/public/assets/flags/bj.svg deleted file mode 100644 index 0846724..0000000 --- a/frontend/public/assets/flags/bj.svg +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/bl.svg b/frontend/public/assets/flags/bl.svg deleted file mode 100644 index f84cbba..0000000 --- a/frontend/public/assets/flags/bl.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/frontend/public/assets/flags/bm.svg b/frontend/public/assets/flags/bm.svg deleted file mode 100644 index f43a5eb..0000000 --- a/frontend/public/assets/flags/bm.svg +++ /dev/null @@ -1,97 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/bn.svg b/frontend/public/assets/flags/bn.svg deleted file mode 100644 index f544c25..0000000 --- a/frontend/public/assets/flags/bn.svg +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/bo.svg b/frontend/public/assets/flags/bo.svg deleted file mode 100644 index 7658e3f..0000000 --- a/frontend/public/assets/flags/bo.svg +++ /dev/null @@ -1,673 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/bq.svg b/frontend/public/assets/flags/bq.svg deleted file mode 100644 index 0e6bc76..0000000 --- a/frontend/public/assets/flags/bq.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/frontend/public/assets/flags/br.svg b/frontend/public/assets/flags/br.svg deleted file mode 100644 index 719a763..0000000 --- a/frontend/public/assets/flags/br.svg +++ /dev/null @@ -1,45 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/bs.svg b/frontend/public/assets/flags/bs.svg deleted file mode 100644 index 5cc918e..0000000 --- a/frontend/public/assets/flags/bs.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/bt.svg b/frontend/public/assets/flags/bt.svg deleted file mode 100644 index 20aef3a..0000000 --- a/frontend/public/assets/flags/bt.svg +++ /dev/null @@ -1,89 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/bv.svg b/frontend/public/assets/flags/bv.svg deleted file mode 100644 index 40e16d9..0000000 --- a/frontend/public/assets/flags/bv.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/bw.svg b/frontend/public/assets/flags/bw.svg deleted file mode 100644 index 3435608..0000000 --- a/frontend/public/assets/flags/bw.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/frontend/public/assets/flags/by.svg b/frontend/public/assets/flags/by.svg deleted file mode 100644 index 948784f..0000000 --- a/frontend/public/assets/flags/by.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/bz.svg b/frontend/public/assets/flags/bz.svg deleted file mode 100644 index d81b16c..0000000 --- a/frontend/public/assets/flags/bz.svg +++ /dev/null @@ -1,145 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/ca.svg b/frontend/public/assets/flags/ca.svg deleted file mode 100644 index c9b23b4..0000000 --- a/frontend/public/assets/flags/ca.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/public/assets/flags/cc.svg b/frontend/public/assets/flags/cc.svg deleted file mode 100644 index a42dec6..0000000 --- a/frontend/public/assets/flags/cc.svg +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/cd.svg b/frontend/public/assets/flags/cd.svg deleted file mode 100644 index b9cf528..0000000 --- a/frontend/public/assets/flags/cd.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/frontend/public/assets/flags/cefta.svg b/frontend/public/assets/flags/cefta.svg deleted file mode 100644 index f748d08..0000000 --- a/frontend/public/assets/flags/cefta.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/cf.svg b/frontend/public/assets/flags/cf.svg deleted file mode 100644 index a6cd367..0000000 --- a/frontend/public/assets/flags/cf.svg +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/cg.svg b/frontend/public/assets/flags/cg.svg deleted file mode 100644 index f5a0e42..0000000 --- a/frontend/public/assets/flags/cg.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/ch.svg b/frontend/public/assets/flags/ch.svg deleted file mode 100644 index b42d670..0000000 --- a/frontend/public/assets/flags/ch.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/frontend/public/assets/flags/ci.svg b/frontend/public/assets/flags/ci.svg deleted file mode 100644 index e400f0c..0000000 --- a/frontend/public/assets/flags/ci.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/frontend/public/assets/flags/ck.svg b/frontend/public/assets/flags/ck.svg deleted file mode 100644 index 18e547b..0000000 --- a/frontend/public/assets/flags/ck.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/frontend/public/assets/flags/cl.svg b/frontend/public/assets/flags/cl.svg deleted file mode 100644 index 5b3c72f..0000000 --- a/frontend/public/assets/flags/cl.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/cm.svg b/frontend/public/assets/flags/cm.svg deleted file mode 100644 index 70adc8b..0000000 --- a/frontend/public/assets/flags/cm.svg +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/cn.svg b/frontend/public/assets/flags/cn.svg deleted file mode 100644 index 10d3489..0000000 --- a/frontend/public/assets/flags/cn.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/frontend/public/assets/flags/co.svg b/frontend/public/assets/flags/co.svg deleted file mode 100644 index ebd0a0f..0000000 --- a/frontend/public/assets/flags/co.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/frontend/public/assets/flags/cp.svg b/frontend/public/assets/flags/cp.svg deleted file mode 100644 index b8aa9cf..0000000 --- a/frontend/public/assets/flags/cp.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/frontend/public/assets/flags/cr.svg b/frontend/public/assets/flags/cr.svg deleted file mode 100644 index 5a409ee..0000000 --- a/frontend/public/assets/flags/cr.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/frontend/public/assets/flags/cu.svg b/frontend/public/assets/flags/cu.svg deleted file mode 100644 index 053c9ee..0000000 --- a/frontend/public/assets/flags/cu.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/cv.svg b/frontend/public/assets/flags/cv.svg deleted file mode 100644 index aec8994..0000000 --- a/frontend/public/assets/flags/cv.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/cw.svg b/frontend/public/assets/flags/cw.svg deleted file mode 100644 index bb0ece2..0000000 --- a/frontend/public/assets/flags/cw.svg +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/cx.svg b/frontend/public/assets/flags/cx.svg deleted file mode 100644 index 3a83c23..0000000 --- a/frontend/public/assets/flags/cx.svg +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/cy.svg b/frontend/public/assets/flags/cy.svg deleted file mode 100644 index ee4b0c7..0000000 --- a/frontend/public/assets/flags/cy.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/frontend/public/assets/flags/cz.svg b/frontend/public/assets/flags/cz.svg deleted file mode 100644 index 7913de3..0000000 --- a/frontend/public/assets/flags/cz.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/frontend/public/assets/flags/de.svg b/frontend/public/assets/flags/de.svg deleted file mode 100644 index 71aa2d2..0000000 --- a/frontend/public/assets/flags/de.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/frontend/public/assets/flags/dg.svg b/frontend/public/assets/flags/dg.svg deleted file mode 100644 index dfee2bb..0000000 --- a/frontend/public/assets/flags/dg.svg +++ /dev/null @@ -1,130 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/dj.svg b/frontend/public/assets/flags/dj.svg deleted file mode 100644 index 9b00a82..0000000 --- a/frontend/public/assets/flags/dj.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/dk.svg b/frontend/public/assets/flags/dk.svg deleted file mode 100644 index 563277f..0000000 --- a/frontend/public/assets/flags/dk.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/frontend/public/assets/flags/dm.svg b/frontend/public/assets/flags/dm.svg deleted file mode 100644 index 5aa9cea..0000000 --- a/frontend/public/assets/flags/dm.svg +++ /dev/null @@ -1,152 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/do.svg b/frontend/public/assets/flags/do.svg deleted file mode 100644 index 6de2b26..0000000 --- a/frontend/public/assets/flags/do.svg +++ /dev/null @@ -1,121 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/dz.svg b/frontend/public/assets/flags/dz.svg deleted file mode 100644 index 5ff29a7..0000000 --- a/frontend/public/assets/flags/dz.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/frontend/public/assets/flags/eac.svg b/frontend/public/assets/flags/eac.svg deleted file mode 100644 index 59d02d2..0000000 --- a/frontend/public/assets/flags/eac.svg +++ /dev/null @@ -1,48 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/ec.svg b/frontend/public/assets/flags/ec.svg deleted file mode 100644 index 88c50bf..0000000 --- a/frontend/public/assets/flags/ec.svg +++ /dev/null @@ -1,138 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/ee.svg b/frontend/public/assets/flags/ee.svg deleted file mode 100644 index 8b98c2c..0000000 --- a/frontend/public/assets/flags/ee.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/frontend/public/assets/flags/eg.svg b/frontend/public/assets/flags/eg.svg deleted file mode 100644 index 88e32b3..0000000 --- a/frontend/public/assets/flags/eg.svg +++ /dev/null @@ -1,38 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/eh.svg b/frontend/public/assets/flags/eh.svg deleted file mode 100644 index 6aec728..0000000 --- a/frontend/public/assets/flags/eh.svg +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/er.svg b/frontend/public/assets/flags/er.svg deleted file mode 100644 index 48a13b4..0000000 --- a/frontend/public/assets/flags/er.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/frontend/public/assets/flags/es-ct.svg b/frontend/public/assets/flags/es-ct.svg deleted file mode 100644 index 4d85911..0000000 --- a/frontend/public/assets/flags/es-ct.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/public/assets/flags/es-ga.svg b/frontend/public/assets/flags/es-ga.svg deleted file mode 100644 index 573ca45..0000000 --- a/frontend/public/assets/flags/es-ga.svg +++ /dev/null @@ -1,187 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/es-pv.svg b/frontend/public/assets/flags/es-pv.svg deleted file mode 100644 index 63c19f4..0000000 --- a/frontend/public/assets/flags/es-pv.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/frontend/public/assets/flags/es.svg b/frontend/public/assets/flags/es.svg deleted file mode 100644 index a296ebf..0000000 --- a/frontend/public/assets/flags/es.svg +++ /dev/null @@ -1,544 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/et.svg b/frontend/public/assets/flags/et.svg deleted file mode 100644 index 3f99be4..0000000 --- a/frontend/public/assets/flags/et.svg +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/eu.svg b/frontend/public/assets/flags/eu.svg deleted file mode 100644 index b0874c1..0000000 --- a/frontend/public/assets/flags/eu.svg +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/fi.svg b/frontend/public/assets/flags/fi.svg deleted file mode 100644 index 470be2d..0000000 --- a/frontend/public/assets/flags/fi.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/frontend/public/assets/flags/fj.svg b/frontend/public/assets/flags/fj.svg deleted file mode 100644 index 332ae61..0000000 --- a/frontend/public/assets/flags/fj.svg +++ /dev/null @@ -1,120 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/fk.svg b/frontend/public/assets/flags/fk.svg deleted file mode 100644 index a0dace8..0000000 --- a/frontend/public/assets/flags/fk.svg +++ /dev/null @@ -1,90 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/fm.svg b/frontend/public/assets/flags/fm.svg deleted file mode 100644 index c1b7c97..0000000 --- a/frontend/public/assets/flags/fm.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/frontend/public/assets/flags/fo.svg b/frontend/public/assets/flags/fo.svg deleted file mode 100644 index f802d28..0000000 --- a/frontend/public/assets/flags/fo.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/fr.svg b/frontend/public/assets/flags/fr.svg deleted file mode 100644 index e682b90..0000000 --- a/frontend/public/assets/flags/fr.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/frontend/public/assets/flags/ga.svg b/frontend/public/assets/flags/ga.svg deleted file mode 100644 index 76edab4..0000000 --- a/frontend/public/assets/flags/ga.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/frontend/public/assets/flags/gb-eng.svg b/frontend/public/assets/flags/gb-eng.svg deleted file mode 100644 index 12e3b67..0000000 --- a/frontend/public/assets/flags/gb-eng.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/frontend/public/assets/flags/gb-nir.svg b/frontend/public/assets/flags/gb-nir.svg deleted file mode 100644 index e22190a..0000000 --- a/frontend/public/assets/flags/gb-nir.svg +++ /dev/null @@ -1,132 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/gb-sct.svg b/frontend/public/assets/flags/gb-sct.svg deleted file mode 100644 index f50cd32..0000000 --- a/frontend/public/assets/flags/gb-sct.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/public/assets/flags/gb-wls.svg b/frontend/public/assets/flags/gb-wls.svg deleted file mode 100644 index d7f5791..0000000 --- a/frontend/public/assets/flags/gb-wls.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/frontend/public/assets/flags/gb.svg b/frontend/public/assets/flags/gb.svg deleted file mode 100644 index 7991383..0000000 --- a/frontend/public/assets/flags/gb.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/frontend/public/assets/flags/gd.svg b/frontend/public/assets/flags/gd.svg deleted file mode 100644 index b3d250d..0000000 --- a/frontend/public/assets/flags/gd.svg +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/ge.svg b/frontend/public/assets/flags/ge.svg deleted file mode 100644 index ab08a9a..0000000 --- a/frontend/public/assets/flags/ge.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/frontend/public/assets/flags/gf.svg b/frontend/public/assets/flags/gf.svg deleted file mode 100644 index f8fe94c..0000000 --- a/frontend/public/assets/flags/gf.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/frontend/public/assets/flags/gg.svg b/frontend/public/assets/flags/gg.svg deleted file mode 100644 index f8216c8..0000000 --- a/frontend/public/assets/flags/gg.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/frontend/public/assets/flags/gh.svg b/frontend/public/assets/flags/gh.svg deleted file mode 100644 index 5c3e3e6..0000000 --- a/frontend/public/assets/flags/gh.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/frontend/public/assets/flags/gi.svg b/frontend/public/assets/flags/gi.svg deleted file mode 100644 index a5d7570..0000000 --- a/frontend/public/assets/flags/gi.svg +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/gl.svg b/frontend/public/assets/flags/gl.svg deleted file mode 100644 index eb5a52e..0000000 --- a/frontend/public/assets/flags/gl.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/public/assets/flags/gm.svg b/frontend/public/assets/flags/gm.svg deleted file mode 100644 index 8fe9d66..0000000 --- a/frontend/public/assets/flags/gm.svg +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/gn.svg b/frontend/public/assets/flags/gn.svg deleted file mode 100644 index 40d6ad4..0000000 --- a/frontend/public/assets/flags/gn.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/frontend/public/assets/flags/gp.svg b/frontend/public/assets/flags/gp.svg deleted file mode 100644 index ee55c4b..0000000 --- a/frontend/public/assets/flags/gp.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/frontend/public/assets/flags/gq.svg b/frontend/public/assets/flags/gq.svg deleted file mode 100644 index 64c8eb2..0000000 --- a/frontend/public/assets/flags/gq.svg +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/gr.svg b/frontend/public/assets/flags/gr.svg deleted file mode 100644 index 599741e..0000000 --- a/frontend/public/assets/flags/gr.svg +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/gs.svg b/frontend/public/assets/flags/gs.svg deleted file mode 100644 index 29db9b9..0000000 --- a/frontend/public/assets/flags/gs.svg +++ /dev/null @@ -1,133 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/gt.svg b/frontend/public/assets/flags/gt.svg deleted file mode 100644 index 7df9df5..0000000 --- a/frontend/public/assets/flags/gt.svg +++ /dev/null @@ -1,204 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/gu.svg b/frontend/public/assets/flags/gu.svg deleted file mode 100644 index 3b95219..0000000 --- a/frontend/public/assets/flags/gu.svg +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/gw.svg b/frontend/public/assets/flags/gw.svg deleted file mode 100644 index d470bac..0000000 --- a/frontend/public/assets/flags/gw.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/gy.svg b/frontend/public/assets/flags/gy.svg deleted file mode 100644 index 569fb56..0000000 --- a/frontend/public/assets/flags/gy.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/frontend/public/assets/flags/hk.svg b/frontend/public/assets/flags/hk.svg deleted file mode 100644 index 4fd55bc..0000000 --- a/frontend/public/assets/flags/hk.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/frontend/public/assets/flags/hm.svg b/frontend/public/assets/flags/hm.svg deleted file mode 100644 index 815c482..0000000 --- a/frontend/public/assets/flags/hm.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/frontend/public/assets/flags/hn.svg b/frontend/public/assets/flags/hn.svg deleted file mode 100644 index 11fde67..0000000 --- a/frontend/public/assets/flags/hn.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/hr.svg b/frontend/public/assets/flags/hr.svg deleted file mode 100644 index dde825c..0000000 --- a/frontend/public/assets/flags/hr.svg +++ /dev/null @@ -1,58 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/ht.svg b/frontend/public/assets/flags/ht.svg deleted file mode 100644 index 8e8efc4..0000000 --- a/frontend/public/assets/flags/ht.svg +++ /dev/null @@ -1,116 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/hu.svg b/frontend/public/assets/flags/hu.svg deleted file mode 100644 index 24fbfb9..0000000 --- a/frontend/public/assets/flags/hu.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/frontend/public/assets/flags/ic.svg b/frontend/public/assets/flags/ic.svg deleted file mode 100644 index 81e6ee2..0000000 --- a/frontend/public/assets/flags/ic.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/frontend/public/assets/flags/id.svg b/frontend/public/assets/flags/id.svg deleted file mode 100644 index 3b7c8fc..0000000 --- a/frontend/public/assets/flags/id.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/public/assets/flags/ie.svg b/frontend/public/assets/flags/ie.svg deleted file mode 100644 index 049be14..0000000 --- a/frontend/public/assets/flags/ie.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/frontend/public/assets/flags/il.svg b/frontend/public/assets/flags/il.svg deleted file mode 100644 index f43be7e..0000000 --- a/frontend/public/assets/flags/il.svg +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/im.svg b/frontend/public/assets/flags/im.svg deleted file mode 100644 index fe6a59a..0000000 --- a/frontend/public/assets/flags/im.svg +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/in.svg b/frontend/public/assets/flags/in.svg deleted file mode 100644 index bc47d74..0000000 --- a/frontend/public/assets/flags/in.svg +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/io.svg b/frontend/public/assets/flags/io.svg deleted file mode 100644 index 3058f7d..0000000 --- a/frontend/public/assets/flags/io.svg +++ /dev/null @@ -1,130 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/iq.svg b/frontend/public/assets/flags/iq.svg deleted file mode 100644 index 8044514..0000000 --- a/frontend/public/assets/flags/iq.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/frontend/public/assets/flags/ir.svg b/frontend/public/assets/flags/ir.svg deleted file mode 100644 index 8c6d516..0000000 --- a/frontend/public/assets/flags/ir.svg +++ /dev/null @@ -1,219 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/is.svg b/frontend/public/assets/flags/is.svg deleted file mode 100644 index a6588af..0000000 --- a/frontend/public/assets/flags/is.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/it.svg b/frontend/public/assets/flags/it.svg deleted file mode 100644 index 20a8bfd..0000000 --- a/frontend/public/assets/flags/it.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/frontend/public/assets/flags/je.svg b/frontend/public/assets/flags/je.svg deleted file mode 100644 index 70a8754..0000000 --- a/frontend/public/assets/flags/je.svg +++ /dev/null @@ -1,62 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/jm.svg b/frontend/public/assets/flags/jm.svg deleted file mode 100644 index 269df03..0000000 --- a/frontend/public/assets/flags/jm.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/frontend/public/assets/flags/jo.svg b/frontend/public/assets/flags/jo.svg deleted file mode 100644 index d6f927d..0000000 --- a/frontend/public/assets/flags/jo.svg +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/jp.svg b/frontend/public/assets/flags/jp.svg deleted file mode 100644 index cc1c181..0000000 --- a/frontend/public/assets/flags/jp.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/frontend/public/assets/flags/ke.svg b/frontend/public/assets/flags/ke.svg deleted file mode 100644 index 3a67ca3..0000000 --- a/frontend/public/assets/flags/ke.svg +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/kg.svg b/frontend/public/assets/flags/kg.svg deleted file mode 100644 index e26db95..0000000 --- a/frontend/public/assets/flags/kg.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/public/assets/flags/kh.svg b/frontend/public/assets/flags/kh.svg deleted file mode 100644 index a7d52f2..0000000 --- a/frontend/public/assets/flags/kh.svg +++ /dev/null @@ -1,61 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/ki.svg b/frontend/public/assets/flags/ki.svg deleted file mode 100644 index fda03f3..0000000 --- a/frontend/public/assets/flags/ki.svg +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/km.svg b/frontend/public/assets/flags/km.svg deleted file mode 100644 index 414d65e..0000000 --- a/frontend/public/assets/flags/km.svg +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/kn.svg b/frontend/public/assets/flags/kn.svg deleted file mode 100644 index 47fe64d..0000000 --- a/frontend/public/assets/flags/kn.svg +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/kp.svg b/frontend/public/assets/flags/kp.svg deleted file mode 100644 index ad1b713..0000000 --- a/frontend/public/assets/flags/kp.svg +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/kr.svg b/frontend/public/assets/flags/kr.svg deleted file mode 100644 index 6947eab..0000000 --- a/frontend/public/assets/flags/kr.svg +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/kw.svg b/frontend/public/assets/flags/kw.svg deleted file mode 100644 index 3dd89e9..0000000 --- a/frontend/public/assets/flags/kw.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/ky.svg b/frontend/public/assets/flags/ky.svg deleted file mode 100644 index aeaa7e0..0000000 --- a/frontend/public/assets/flags/ky.svg +++ /dev/null @@ -1,103 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/kz.svg b/frontend/public/assets/flags/kz.svg deleted file mode 100644 index 2fac45b..0000000 --- a/frontend/public/assets/flags/kz.svg +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/la.svg b/frontend/public/assets/flags/la.svg deleted file mode 100644 index 6aea6b7..0000000 --- a/frontend/public/assets/flags/la.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/lb.svg b/frontend/public/assets/flags/lb.svg deleted file mode 100644 index bde2581..0000000 --- a/frontend/public/assets/flags/lb.svg +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/lc.svg b/frontend/public/assets/flags/lc.svg deleted file mode 100644 index bb25654..0000000 --- a/frontend/public/assets/flags/lc.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/frontend/public/assets/flags/li.svg b/frontend/public/assets/flags/li.svg deleted file mode 100644 index 7a4d183..0000000 --- a/frontend/public/assets/flags/li.svg +++ /dev/null @@ -1,43 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/lk.svg b/frontend/public/assets/flags/lk.svg deleted file mode 100644 index cbd660a..0000000 --- a/frontend/public/assets/flags/lk.svg +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/lr.svg b/frontend/public/assets/flags/lr.svg deleted file mode 100644 index e482ab9..0000000 --- a/frontend/public/assets/flags/lr.svg +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/ls.svg b/frontend/public/assets/flags/ls.svg deleted file mode 100644 index a7c01a9..0000000 --- a/frontend/public/assets/flags/ls.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/frontend/public/assets/flags/lt.svg b/frontend/public/assets/flags/lt.svg deleted file mode 100644 index 90ec5d2..0000000 --- a/frontend/public/assets/flags/lt.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/frontend/public/assets/flags/lu.svg b/frontend/public/assets/flags/lu.svg deleted file mode 100644 index cc12206..0000000 --- a/frontend/public/assets/flags/lu.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/frontend/public/assets/flags/lv.svg b/frontend/public/assets/flags/lv.svg deleted file mode 100644 index f6decec..0000000 --- a/frontend/public/assets/flags/lv.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/frontend/public/assets/flags/ly.svg b/frontend/public/assets/flags/ly.svg deleted file mode 100644 index 1eaa51e..0000000 --- a/frontend/public/assets/flags/ly.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/ma.svg b/frontend/public/assets/flags/ma.svg deleted file mode 100644 index 7ce56ef..0000000 --- a/frontend/public/assets/flags/ma.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/public/assets/flags/mc.svg b/frontend/public/assets/flags/mc.svg deleted file mode 100644 index 9cb6c9e..0000000 --- a/frontend/public/assets/flags/mc.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/frontend/public/assets/flags/md.svg b/frontend/public/assets/flags/md.svg deleted file mode 100644 index e9ba506..0000000 --- a/frontend/public/assets/flags/md.svg +++ /dev/null @@ -1,70 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/me.svg b/frontend/public/assets/flags/me.svg deleted file mode 100644 index 297888c..0000000 --- a/frontend/public/assets/flags/me.svg +++ /dev/null @@ -1,116 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/mf.svg b/frontend/public/assets/flags/mf.svg deleted file mode 100644 index 6305edc..0000000 --- a/frontend/public/assets/flags/mf.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/frontend/public/assets/flags/mg.svg b/frontend/public/assets/flags/mg.svg deleted file mode 100644 index 5fa2d24..0000000 --- a/frontend/public/assets/flags/mg.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/frontend/public/assets/flags/mh.svg b/frontend/public/assets/flags/mh.svg deleted file mode 100644 index 7b9f490..0000000 --- a/frontend/public/assets/flags/mh.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/frontend/public/assets/flags/mk.svg b/frontend/public/assets/flags/mk.svg deleted file mode 100644 index 4f5cae7..0000000 --- a/frontend/public/assets/flags/mk.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/frontend/public/assets/flags/ml.svg b/frontend/public/assets/flags/ml.svg deleted file mode 100644 index 6f6b716..0000000 --- a/frontend/public/assets/flags/ml.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/frontend/public/assets/flags/mm.svg b/frontend/public/assets/flags/mm.svg deleted file mode 100644 index 42b4dee..0000000 --- a/frontend/public/assets/flags/mm.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/mn.svg b/frontend/public/assets/flags/mn.svg deleted file mode 100644 index 6a38a71..0000000 --- a/frontend/public/assets/flags/mn.svg +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/mo.svg b/frontend/public/assets/flags/mo.svg deleted file mode 100644 index f638b6c..0000000 --- a/frontend/public/assets/flags/mo.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/frontend/public/assets/flags/mp.svg b/frontend/public/assets/flags/mp.svg deleted file mode 100644 index 26bfa22..0000000 --- a/frontend/public/assets/flags/mp.svg +++ /dev/null @@ -1,86 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/mq.svg b/frontend/public/assets/flags/mq.svg deleted file mode 100644 index b221951..0000000 --- a/frontend/public/assets/flags/mq.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/frontend/public/assets/flags/mr.svg b/frontend/public/assets/flags/mr.svg deleted file mode 100644 index d859972..0000000 --- a/frontend/public/assets/flags/mr.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/frontend/public/assets/flags/ms.svg b/frontend/public/assets/flags/ms.svg deleted file mode 100644 index 4367505..0000000 --- a/frontend/public/assets/flags/ms.svg +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/mt.svg b/frontend/public/assets/flags/mt.svg deleted file mode 100644 index 5d5d7c8..0000000 --- a/frontend/public/assets/flags/mt.svg +++ /dev/null @@ -1,58 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/mu.svg b/frontend/public/assets/flags/mu.svg deleted file mode 100644 index 82d7a3b..0000000 --- a/frontend/public/assets/flags/mu.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/frontend/public/assets/flags/mv.svg b/frontend/public/assets/flags/mv.svg deleted file mode 100644 index 10450f9..0000000 --- a/frontend/public/assets/flags/mv.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/frontend/public/assets/flags/mw.svg b/frontend/public/assets/flags/mw.svg deleted file mode 100644 index 137ff87..0000000 --- a/frontend/public/assets/flags/mw.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/frontend/public/assets/flags/mx.svg b/frontend/public/assets/flags/mx.svg deleted file mode 100644 index e3ec2bc..0000000 --- a/frontend/public/assets/flags/mx.svg +++ /dev/null @@ -1,382 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/my.svg b/frontend/public/assets/flags/my.svg deleted file mode 100644 index 115f864..0000000 --- a/frontend/public/assets/flags/my.svg +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/mz.svg b/frontend/public/assets/flags/mz.svg deleted file mode 100644 index 0f94c3a..0000000 --- a/frontend/public/assets/flags/mz.svg +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/na.svg b/frontend/public/assets/flags/na.svg deleted file mode 100644 index 35b9f78..0000000 --- a/frontend/public/assets/flags/na.svg +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/nc.svg b/frontend/public/assets/flags/nc.svg deleted file mode 100644 index fa15551..0000000 --- a/frontend/public/assets/flags/nc.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/ne.svg b/frontend/public/assets/flags/ne.svg deleted file mode 100644 index 39a82b8..0000000 --- a/frontend/public/assets/flags/ne.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/frontend/public/assets/flags/nf.svg b/frontend/public/assets/flags/nf.svg deleted file mode 100644 index fd61b25..0000000 --- a/frontend/public/assets/flags/nf.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/frontend/public/assets/flags/ng.svg b/frontend/public/assets/flags/ng.svg deleted file mode 100644 index 81eb35f..0000000 --- a/frontend/public/assets/flags/ng.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/frontend/public/assets/flags/ni.svg b/frontend/public/assets/flags/ni.svg deleted file mode 100644 index e4861f5..0000000 --- a/frontend/public/assets/flags/ni.svg +++ /dev/null @@ -1,129 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/nl.svg b/frontend/public/assets/flags/nl.svg deleted file mode 100644 index e90f5b0..0000000 --- a/frontend/public/assets/flags/nl.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/frontend/public/assets/flags/no.svg b/frontend/public/assets/flags/no.svg deleted file mode 100644 index a5f2a15..0000000 --- a/frontend/public/assets/flags/no.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/frontend/public/assets/flags/np.svg b/frontend/public/assets/flags/np.svg deleted file mode 100644 index 6242856..0000000 --- a/frontend/public/assets/flags/np.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/nr.svg b/frontend/public/assets/flags/nr.svg deleted file mode 100644 index ff394c4..0000000 --- a/frontend/public/assets/flags/nr.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/nu.svg b/frontend/public/assets/flags/nu.svg deleted file mode 100644 index 4067baf..0000000 --- a/frontend/public/assets/flags/nu.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/frontend/public/assets/flags/nz.svg b/frontend/public/assets/flags/nz.svg deleted file mode 100644 index 935d8a7..0000000 --- a/frontend/public/assets/flags/nz.svg +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/om.svg b/frontend/public/assets/flags/om.svg deleted file mode 100644 index 4f1461a..0000000 --- a/frontend/public/assets/flags/om.svg +++ /dev/null @@ -1,115 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/pa.svg b/frontend/public/assets/flags/pa.svg deleted file mode 100644 index 9ab733f..0000000 --- a/frontend/public/assets/flags/pa.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/frontend/public/assets/flags/pc.svg b/frontend/public/assets/flags/pc.svg deleted file mode 100644 index 5202d6d..0000000 --- a/frontend/public/assets/flags/pc.svg +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/pe.svg b/frontend/public/assets/flags/pe.svg deleted file mode 100644 index 33e6cfd..0000000 --- a/frontend/public/assets/flags/pe.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/public/assets/flags/pf.svg b/frontend/public/assets/flags/pf.svg deleted file mode 100644 index bea0354..0000000 --- a/frontend/public/assets/flags/pf.svg +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/pg.svg b/frontend/public/assets/flags/pg.svg deleted file mode 100644 index 7b7e77a..0000000 --- a/frontend/public/assets/flags/pg.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/frontend/public/assets/flags/ph.svg b/frontend/public/assets/flags/ph.svg deleted file mode 100644 index b910e24..0000000 --- a/frontend/public/assets/flags/ph.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/frontend/public/assets/flags/pk.svg b/frontend/public/assets/flags/pk.svg deleted file mode 100644 index 4ddc19f..0000000 --- a/frontend/public/assets/flags/pk.svg +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/pl.svg b/frontend/public/assets/flags/pl.svg deleted file mode 100644 index 42d2b0c..0000000 --- a/frontend/public/assets/flags/pl.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/frontend/public/assets/flags/pm.svg b/frontend/public/assets/flags/pm.svg deleted file mode 100644 index 19a9330..0000000 --- a/frontend/public/assets/flags/pm.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/frontend/public/assets/flags/pn.svg b/frontend/public/assets/flags/pn.svg deleted file mode 100644 index 209ea71..0000000 --- a/frontend/public/assets/flags/pn.svg +++ /dev/null @@ -1,53 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/pr.svg b/frontend/public/assets/flags/pr.svg deleted file mode 100644 index ec51831..0000000 --- a/frontend/public/assets/flags/pr.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/ps.svg b/frontend/public/assets/flags/ps.svg deleted file mode 100644 index 362d435..0000000 --- a/frontend/public/assets/flags/ps.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/frontend/public/assets/flags/pt.svg b/frontend/public/assets/flags/pt.svg deleted file mode 100644 index 2767cd4..0000000 --- a/frontend/public/assets/flags/pt.svg +++ /dev/null @@ -1,57 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/pw.svg b/frontend/public/assets/flags/pw.svg deleted file mode 100644 index 9f89c5f..0000000 --- a/frontend/public/assets/flags/pw.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/frontend/public/assets/flags/py.svg b/frontend/public/assets/flags/py.svg deleted file mode 100644 index abccd87..0000000 --- a/frontend/public/assets/flags/py.svg +++ /dev/null @@ -1,157 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/qa.svg b/frontend/public/assets/flags/qa.svg deleted file mode 100644 index 901f3fa..0000000 --- a/frontend/public/assets/flags/qa.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/public/assets/flags/re.svg b/frontend/public/assets/flags/re.svg deleted file mode 100644 index 64e788e..0000000 --- a/frontend/public/assets/flags/re.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/frontend/public/assets/flags/ro.svg b/frontend/public/assets/flags/ro.svg deleted file mode 100644 index fda0f7b..0000000 --- a/frontend/public/assets/flags/ro.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/frontend/public/assets/flags/rs.svg b/frontend/public/assets/flags/rs.svg deleted file mode 100644 index 6d4f74d..0000000 --- a/frontend/public/assets/flags/rs.svg +++ /dev/null @@ -1,292 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/ru.svg b/frontend/public/assets/flags/ru.svg deleted file mode 100644 index cf24301..0000000 --- a/frontend/public/assets/flags/ru.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/frontend/public/assets/flags/rw.svg b/frontend/public/assets/flags/rw.svg deleted file mode 100644 index 06e26ae..0000000 --- a/frontend/public/assets/flags/rw.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/sa.svg b/frontend/public/assets/flags/sa.svg deleted file mode 100644 index 596cf48..0000000 --- a/frontend/public/assets/flags/sa.svg +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/sb.svg b/frontend/public/assets/flags/sb.svg deleted file mode 100644 index 6066f94..0000000 --- a/frontend/public/assets/flags/sb.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/sc.svg b/frontend/public/assets/flags/sc.svg deleted file mode 100644 index 9a46b36..0000000 --- a/frontend/public/assets/flags/sc.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/frontend/public/assets/flags/sd.svg b/frontend/public/assets/flags/sd.svg deleted file mode 100644 index 12818b4..0000000 --- a/frontend/public/assets/flags/sd.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/se.svg b/frontend/public/assets/flags/se.svg deleted file mode 100644 index 8ba745a..0000000 --- a/frontend/public/assets/flags/se.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/public/assets/flags/sg.svg b/frontend/public/assets/flags/sg.svg deleted file mode 100644 index c4dd4ac..0000000 --- a/frontend/public/assets/flags/sg.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/sh-ac.svg b/frontend/public/assets/flags/sh-ac.svg deleted file mode 100644 index c43b301..0000000 --- a/frontend/public/assets/flags/sh-ac.svg +++ /dev/null @@ -1,689 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/sh-hl.svg b/frontend/public/assets/flags/sh-hl.svg deleted file mode 100644 index 2150bf6..0000000 --- a/frontend/public/assets/flags/sh-hl.svg +++ /dev/null @@ -1,164 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/sh-ta.svg b/frontend/public/assets/flags/sh-ta.svg deleted file mode 100644 index ba39063..0000000 --- a/frontend/public/assets/flags/sh-ta.svg +++ /dev/null @@ -1,76 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/sh.svg b/frontend/public/assets/flags/sh.svg deleted file mode 100644 index 7aba0ae..0000000 --- a/frontend/public/assets/flags/sh.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/frontend/public/assets/flags/si.svg b/frontend/public/assets/flags/si.svg deleted file mode 100644 index 1bbdd94..0000000 --- a/frontend/public/assets/flags/si.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/sj.svg b/frontend/public/assets/flags/sj.svg deleted file mode 100644 index bb2799c..0000000 --- a/frontend/public/assets/flags/sj.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/frontend/public/assets/flags/sk.svg b/frontend/public/assets/flags/sk.svg deleted file mode 100644 index 676018e..0000000 --- a/frontend/public/assets/flags/sk.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/frontend/public/assets/flags/sl.svg b/frontend/public/assets/flags/sl.svg deleted file mode 100644 index a07baf7..0000000 --- a/frontend/public/assets/flags/sl.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/frontend/public/assets/flags/sm.svg b/frontend/public/assets/flags/sm.svg deleted file mode 100644 index e41d2f7..0000000 --- a/frontend/public/assets/flags/sm.svg +++ /dev/null @@ -1,75 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/sn.svg b/frontend/public/assets/flags/sn.svg deleted file mode 100644 index 7c0673d..0000000 --- a/frontend/public/assets/flags/sn.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/frontend/public/assets/flags/so.svg b/frontend/public/assets/flags/so.svg deleted file mode 100644 index a581ac6..0000000 --- a/frontend/public/assets/flags/so.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/frontend/public/assets/flags/sr.svg b/frontend/public/assets/flags/sr.svg deleted file mode 100644 index 5e71c40..0000000 --- a/frontend/public/assets/flags/sr.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/frontend/public/assets/flags/ss.svg b/frontend/public/assets/flags/ss.svg deleted file mode 100644 index b257aa0..0000000 --- a/frontend/public/assets/flags/ss.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/frontend/public/assets/flags/st.svg b/frontend/public/assets/flags/st.svg deleted file mode 100644 index 1294bcb..0000000 --- a/frontend/public/assets/flags/st.svg +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/sv.svg b/frontend/public/assets/flags/sv.svg deleted file mode 100644 index cbc674a..0000000 --- a/frontend/public/assets/flags/sv.svg +++ /dev/null @@ -1,593 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/sx.svg b/frontend/public/assets/flags/sx.svg deleted file mode 100644 index ac78561..0000000 --- a/frontend/public/assets/flags/sx.svg +++ /dev/null @@ -1,56 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/sy.svg b/frontend/public/assets/flags/sy.svg deleted file mode 100644 index 97c05cf..0000000 --- a/frontend/public/assets/flags/sy.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/frontend/public/assets/flags/sz.svg b/frontend/public/assets/flags/sz.svg deleted file mode 100644 index eb538e4..0000000 --- a/frontend/public/assets/flags/sz.svg +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/tc.svg b/frontend/public/assets/flags/tc.svg deleted file mode 100644 index 1258971..0000000 --- a/frontend/public/assets/flags/tc.svg +++ /dev/null @@ -1,50 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/td.svg b/frontend/public/assets/flags/td.svg deleted file mode 100644 index fa3bd92..0000000 --- a/frontend/public/assets/flags/td.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/frontend/public/assets/flags/tf.svg b/frontend/public/assets/flags/tf.svg deleted file mode 100644 index fba2335..0000000 --- a/frontend/public/assets/flags/tf.svg +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/tg.svg b/frontend/public/assets/flags/tg.svg deleted file mode 100644 index 9d6ea6c..0000000 --- a/frontend/public/assets/flags/tg.svg +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/th.svg b/frontend/public/assets/flags/th.svg deleted file mode 100644 index 1e93a61..0000000 --- a/frontend/public/assets/flags/th.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/frontend/public/assets/flags/tj.svg b/frontend/public/assets/flags/tj.svg deleted file mode 100644 index f8c9a03..0000000 --- a/frontend/public/assets/flags/tj.svg +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/tk.svg b/frontend/public/assets/flags/tk.svg deleted file mode 100644 index 05d3e86..0000000 --- a/frontend/public/assets/flags/tk.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/frontend/public/assets/flags/tl.svg b/frontend/public/assets/flags/tl.svg deleted file mode 100644 index 3d0701a..0000000 --- a/frontend/public/assets/flags/tl.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/tm.svg b/frontend/public/assets/flags/tm.svg deleted file mode 100644 index 4154ed7..0000000 --- a/frontend/public/assets/flags/tm.svg +++ /dev/null @@ -1,204 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/tn.svg b/frontend/public/assets/flags/tn.svg deleted file mode 100644 index 5735c19..0000000 --- a/frontend/public/assets/flags/tn.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/public/assets/flags/to.svg b/frontend/public/assets/flags/to.svg deleted file mode 100644 index d072337..0000000 --- a/frontend/public/assets/flags/to.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/frontend/public/assets/flags/tr.svg b/frontend/public/assets/flags/tr.svg deleted file mode 100644 index b96da21..0000000 --- a/frontend/public/assets/flags/tr.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/frontend/public/assets/flags/tt.svg b/frontend/public/assets/flags/tt.svg deleted file mode 100644 index bc24938..0000000 --- a/frontend/public/assets/flags/tt.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/frontend/public/assets/flags/tv.svg b/frontend/public/assets/flags/tv.svg deleted file mode 100644 index 675210e..0000000 --- a/frontend/public/assets/flags/tv.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/frontend/public/assets/flags/tw.svg b/frontend/public/assets/flags/tw.svg deleted file mode 100644 index 57fd98b..0000000 --- a/frontend/public/assets/flags/tw.svg +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/tz.svg b/frontend/public/assets/flags/tz.svg deleted file mode 100644 index a2cfbca..0000000 --- a/frontend/public/assets/flags/tz.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/ua.svg b/frontend/public/assets/flags/ua.svg deleted file mode 100644 index 03daa19..0000000 --- a/frontend/public/assets/flags/ua.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/frontend/public/assets/flags/ug.svg b/frontend/public/assets/flags/ug.svg deleted file mode 100644 index 520eee5..0000000 --- a/frontend/public/assets/flags/ug.svg +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/um.svg b/frontend/public/assets/flags/um.svg deleted file mode 100644 index 9e9edda..0000000 --- a/frontend/public/assets/flags/um.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/frontend/public/assets/flags/un.svg b/frontend/public/assets/flags/un.svg deleted file mode 100644 index 632bbb4..0000000 --- a/frontend/public/assets/flags/un.svg +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/us.svg b/frontend/public/assets/flags/us.svg deleted file mode 100644 index 9cfd0c9..0000000 --- a/frontend/public/assets/flags/us.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/frontend/public/assets/flags/uy.svg b/frontend/public/assets/flags/uy.svg deleted file mode 100644 index 62c36f8..0000000 --- a/frontend/public/assets/flags/uy.svg +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/uz.svg b/frontend/public/assets/flags/uz.svg deleted file mode 100644 index 0ccca1b..0000000 --- a/frontend/public/assets/flags/uz.svg +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/va.svg b/frontend/public/assets/flags/va.svg deleted file mode 100644 index 3e297d6..0000000 --- a/frontend/public/assets/flags/va.svg +++ /dev/null @@ -1,190 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/vc.svg b/frontend/public/assets/flags/vc.svg deleted file mode 100644 index f26c2d8..0000000 --- a/frontend/public/assets/flags/vc.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/frontend/public/assets/flags/ve.svg b/frontend/public/assets/flags/ve.svg deleted file mode 100644 index 314e7f5..0000000 --- a/frontend/public/assets/flags/ve.svg +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/vg.svg b/frontend/public/assets/flags/vg.svg deleted file mode 100644 index ac90088..0000000 --- a/frontend/public/assets/flags/vg.svg +++ /dev/null @@ -1,59 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/vi.svg b/frontend/public/assets/flags/vi.svg deleted file mode 100644 index d88d68f..0000000 --- a/frontend/public/assets/flags/vi.svg +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/vn.svg b/frontend/public/assets/flags/vn.svg deleted file mode 100644 index 7e4bac8..0000000 --- a/frontend/public/assets/flags/vn.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/frontend/public/assets/flags/vu.svg b/frontend/public/assets/flags/vu.svg deleted file mode 100644 index 326d29e..0000000 --- a/frontend/public/assets/flags/vu.svg +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/wf.svg b/frontend/public/assets/flags/wf.svg deleted file mode 100644 index 054c57d..0000000 --- a/frontend/public/assets/flags/wf.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/frontend/public/assets/flags/ws.svg b/frontend/public/assets/flags/ws.svg deleted file mode 100644 index 0e758a7..0000000 --- a/frontend/public/assets/flags/ws.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/frontend/public/assets/flags/xk.svg b/frontend/public/assets/flags/xk.svg deleted file mode 100644 index 0e8958d..0000000 --- a/frontend/public/assets/flags/xk.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/frontend/public/assets/flags/xx.svg b/frontend/public/assets/flags/xx.svg deleted file mode 100644 index 9333be3..0000000 --- a/frontend/public/assets/flags/xx.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/frontend/public/assets/flags/ye.svg b/frontend/public/assets/flags/ye.svg deleted file mode 100644 index 1c9e6d6..0000000 --- a/frontend/public/assets/flags/ye.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/frontend/public/assets/flags/yt.svg b/frontend/public/assets/flags/yt.svg deleted file mode 100644 index e7776b3..0000000 --- a/frontend/public/assets/flags/yt.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/frontend/public/assets/flags/za.svg b/frontend/public/assets/flags/za.svg deleted file mode 100644 index d563adb..0000000 --- a/frontend/public/assets/flags/za.svg +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/zm.svg b/frontend/public/assets/flags/zm.svg deleted file mode 100644 index 360f37a..0000000 --- a/frontend/public/assets/flags/zm.svg +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/assets/flags/zw.svg b/frontend/public/assets/flags/zw.svg deleted file mode 100644 index 93aac4f..0000000 --- a/frontend/public/assets/flags/zw.svg +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/icon.svg b/frontend/public/icon.svg deleted file mode 100644 index da7bd7f..0000000 --- a/frontend/public/icon.svg +++ /dev/null @@ -1,39 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/frontend/scripts/generate-icon.js b/frontend/scripts/generate-icon.js deleted file mode 100644 index 02eec68..0000000 --- a/frontend/scripts/generate-icon.js +++ /dev/null @@ -1,24 +0,0 @@ -import sharp from 'sharp'; -import { readFileSync, mkdirSync } from 'fs'; -import { dirname, join } from 'path'; -import { fileURLToPath } from 'url'; -const __dirname = dirname(fileURLToPath(import.meta.url)); -const rootDir = join(__dirname, '..', '..'); -const svgPath = join(rootDir, 'frontend', 'public', 'icon.svg'); -const outputPath = join(rootDir, 'build', 'appicon.png'); -async function generateIcon() { - try { - mkdirSync(join(rootDir, 'build'), { recursive: true }); - const svgBuffer = readFileSync(svgPath); - await sharp(svgBuffer) - .resize(1024, 1024) - .png() - .toFile(outputPath); - console.log('✓ Icon generated:', outputPath); - } - catch (error) { - console.error('✗ Failed to generate icon:', error.message); - process.exit(1); - } -} -generateIcon(); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx deleted file mode 100644 index e71853c..0000000 --- a/frontend/src/App.tsx +++ /dev/null @@ -1,726 +0,0 @@ -import { useState, useEffect, useCallback, useLayoutEffect, useRef } from "react"; -import { Button } from "@/components/ui/button"; -import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { Search, X, ArrowUp } from "lucide-react"; -import { TooltipProvider } from "@/components/ui/tooltip"; -import { getSettings, getSettingsWithDefaults, loadSettings, saveSettings, applyThemeMode, applyFont } from "@/lib/settings"; -import { applyTheme } from "@/lib/themes"; -import { OpenFolder, CheckFFmpegInstalled, DownloadFFmpeg, GetRecentFetches, SaveRecentFetches } from "../wailsjs/go/main/App"; -import { EventsOn, EventsOff, Quit } from "../wailsjs/runtime/runtime"; -import { toastWithSound as toast } from "@/lib/toast-with-sound"; -import { TitleBar } from "@/components/TitleBar"; -import { Sidebar, type PageType } from "@/components/Sidebar"; -import { Header } from "@/components/Header"; -import { SearchBar } from "@/components/SearchBar"; -import { TrackInfo } from "@/components/TrackInfo"; -import { AlbumInfo } from "@/components/AlbumInfo"; -import { PlaylistInfo } from "@/components/PlaylistInfo"; -import { ArtistInfo } from "@/components/ArtistInfo"; -import { DownloadQueue } from "@/components/DownloadQueue"; -import { DownloadProgressToast } from "@/components/DownloadProgressToast"; -import { AudioAnalysisPage } from "@/components/AudioAnalysisPage"; -import { AudioConverterPage } from "@/components/AudioConverterPage"; -import { AudioResamplerPage } from "@/components/AudioResamplerPage"; -import { FileManagerPage } from "@/components/FileManagerPage"; -import { SettingsPage } from "@/components/SettingsPage"; -import { DebugLoggerPage } from "@/components/DebugLoggerPage"; -import { OtherProjects } from "@/components/OtherProjects"; -import { HistoryPage } from "@/components/HistoryPage"; -import { SupportPage } from "@/components/SupportPage"; -import type { HistoryItem } from "@/components/FetchHistory"; -import { useDownload } from "@/hooks/useDownload"; -import { useMetadata } from "@/hooks/useMetadata"; -import { useLyrics } from "@/hooks/useLyrics"; -import { useCover } from "@/hooks/useCover"; -import { useAvailability } from "@/hooks/useAvailability"; -import { ensureApiStatusCheckStarted } from "@/lib/api-status"; -import { useDownloadQueueDialog } from "@/hooks/useDownloadQueueDialog"; -import { useDownloadProgress } from "@/hooks/useDownloadProgress"; -import { buildPlaylistFolderName } from "@/lib/playlist"; -const HISTORY_KEY = "spotiflac_fetch_history"; -const MAX_HISTORY = 5; -function extractSpotifyEntityFromURL(url: string): { - type: string; - id: string; -} | null { - const trimmed = url.trim(); - if (!trimmed) { - return null; - } - const spotifyUriMatch = trimmed.match(/^spotify:(track|album|playlist|artist):([A-Za-z0-9]+)$/i); - if (spotifyUriMatch) { - return { - type: spotifyUriMatch[1].toLowerCase(), - id: spotifyUriMatch[2], - }; - } - try { - const parsed = new URL(trimmed); - const segments = parsed.pathname.split("/").filter(Boolean); - const supportedTypes = new Set(["track", "album", "playlist", "artist"]); - for (let i = 0; i < segments.length - 1; i++) { - const segment = segments[i].toLowerCase(); - if (!supportedTypes.has(segment)) { - continue; - } - const id = segments[i + 1]; - if (id) { - return { type: segment, id }; - } - } - } - catch { - } - return null; -} -function normalizeHistoryURL(url: string): string { - const trimmed = url.trim(); - if (!trimmed) - return trimmed; - const withoutQuery = trimmed.split("?")[0].replace(/\/+$/, ""); - const spotifyEntity = extractSpotifyEntityFromURL(withoutQuery); - if (spotifyEntity) { - return `https://open.spotify.com/${spotifyEntity.type}/${spotifyEntity.id}`; - } - return withoutQuery.replace(/(\/artist\/[A-Za-z0-9]+)\/discography\/all$/i, "$1"); -} -function getHistoryIdentityKey(type: HistoryItem["type"], url: string): string { - const normalizedUrl = normalizeHistoryURL(url); - const spotifyEntity = extractSpotifyEntityFromURL(normalizedUrl); - if (spotifyEntity) { - return `${type}:${spotifyEntity.id}`; - } - return `${type}:${normalizedUrl}`; -} -function dedupeHistoryItems(items: HistoryItem[]): HistoryItem[] { - const seen = new Set(); - 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 sortHistoryItems(items: HistoryItem[]): HistoryItem[] { - return [...items].sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0)); -} -function normalizeHistoryItems(items: HistoryItem[]): HistoryItem[] { - return dedupeHistoryItems(sortHistoryItems(items)).slice(0, MAX_HISTORY); -} -function parseStoredHistory(value: string | null): HistoryItem[] { - if (!value) { - return []; - } - try { - const parsed = JSON.parse(value); - return Array.isArray(parsed) ? parsed : []; - } - catch (err) { - console.error("Failed to parse stored history:", err); - return []; - } -} -function App() { - const [currentPage, setCurrentPage] = useState("main"); - const contentScrollRef = useRef(null); - const [spotifyUrl, setSpotifyUrl] = useState(""); - const [selectedTracks, setSelectedTracks] = useState([]); - const [searchQuery, setSearchQuery] = useState(""); - const [sortBy, setSortBy] = useState("default"); - const [currentListPage, setCurrentListPage] = useState(1); - const [hasUpdate, setHasUpdate] = useState(false); - const [releaseDate, setReleaseDate] = useState(null); - const [fetchHistory, setFetchHistory] = useState([]); - const [isSearchMode, setIsSearchMode] = useState(false); - const [region, setRegion] = useState(() => localStorage.getItem("spotiflac_region") || "US"); - useEffect(() => { - localStorage.setItem("spotiflac_region", region); - }, [region]); - const [showScrollTop, setShowScrollTop] = useState(false); - const [hasUnsavedSettings, setHasUnsavedSettings] = useState(false); - const [pendingPageChange, setPendingPageChange] = useState(null); - const [showUnsavedChangesDialog, setShowUnsavedChangesDialog] = useState(false); - const [resetSettingsFn, setResetSettingsFn] = useState<(() => void) | null>(null); - const ITEMS_PER_PAGE = 50; - const CURRENT_VERSION = __APP_VERSION__; - const download = useDownload(region); - const metadata = useMetadata(); - const lyrics = useLyrics(); - const cover = useCover(); - const availability = useAvailability(); - const downloadQueue = useDownloadQueueDialog(); - const downloadProgress = useDownloadProgress(); - const [isFFmpegInstalled, setIsFFmpegInstalled] = useState(null); - const [isInstallingFFmpeg, setIsInstallingFFmpeg] = useState(false); - const [ffmpegInstallProgress, setFfmpegInstallProgress] = useState(0); - const [ffmpegInstallStatus, setFfmpegInstallStatus] = useState(""); - useLayoutEffect(() => { - const savedSettings = getSettings(); - if (savedSettings) { - applyThemeMode(savedSettings.themeMode); - applyTheme(savedSettings.theme); - applyFont(savedSettings.fontFamily, savedSettings.customFonts); - } - }, []); - useEffect(() => { - const initSettings = async () => { - const settings = await loadSettings(); - applyThemeMode(settings.themeMode); - applyTheme(settings.theme); - applyFont(settings.fontFamily, settings.customFonts); - if (!settings.downloadPath) { - const settingsWithDefaults = await getSettingsWithDefaults(); - await saveSettings(settingsWithDefaults); - } - }; - initSettings(); - const checkFFmpeg = async () => { - try { - const installed = await CheckFFmpegInstalled(); - setIsFFmpegInstalled(installed); - } - catch (err) { - console.error("Failed to check FFmpeg:", err); - setIsFFmpegInstalled(false); - } - }; - checkFFmpeg(); - const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); - const handleChange = () => { - const currentSettings = getSettings(); - if (currentSettings.themeMode === "auto") { - applyThemeMode("auto"); - applyTheme(currentSettings.theme); - } - }; - mediaQuery.addEventListener("change", handleChange); - checkForUpdates(); - ensureApiStatusCheckStarted(); - void loadHistory(); - return () => { - mediaQuery.removeEventListener("change", handleChange); - }; - }, []); - useEffect(() => { - const contentElement = contentScrollRef.current; - if (!contentElement) { - return; - } - const handleScroll = () => { - setShowScrollTop(contentElement.scrollTop > 300); - }; - handleScroll(); - contentElement.addEventListener("scroll", handleScroll, { passive: true }); - return () => { - contentElement.removeEventListener("scroll", handleScroll); - }; - }, []); - const scrollToTop = useCallback(() => { - contentScrollRef.current?.scrollTo({ top: 0, behavior: "smooth" }); - }, []); - useEffect(() => { - contentScrollRef.current?.scrollTo({ top: 0, behavior: "auto" }); - setShowScrollTop(false); - }, [currentPage]); - useEffect(() => { - setSelectedTracks([]); - setSearchQuery(""); - download.resetDownloadedTracks(); - lyrics.resetLyricsState(); - cover.resetCoverState(); - availability.clearAvailability(); - setSortBy("default"); - setCurrentListPage(1); - }, [metadata.metadata]); - const checkForUpdates = async () => { - try { - const response = await fetch("https://api.github.com/repos/afkarxyz/SpotiFLAC/releases/latest"); - const data = await response.json(); - const latestVersion = data.tag_name?.replace(/^v/, "") || ""; - if (data.published_at) { - setReleaseDate(data.published_at); - } - if (latestVersion && latestVersion > CURRENT_VERSION) { - setHasUpdate(true); - } - } - catch (err) { - console.error("Failed to check for updates:", err); - } - }; - const persistRecentHistory = useCallback(async (history: HistoryItem[]) => { - try { - await SaveRecentFetches(JSON.stringify(history)); - } - catch (err) { - console.error("Failed to save recent fetches:", err); - } - }, []); - const loadHistory = useCallback(async () => { - try { - const saved = parseStoredHistory(localStorage.getItem(HISTORY_KEY)); - const persisted = parseStoredHistory(await GetRecentFetches()); - const normalized = normalizeHistoryItems([...persisted, ...saved]); - setFetchHistory(normalized); - await persistRecentHistory(normalized); - } - catch (err) { - console.error("Failed to load history:", err); - } - finally { - localStorage.removeItem(HISTORY_KEY); - } - }, [persistRecentHistory]); - const handleInstallFFmpeg = async () => { - setIsInstallingFFmpeg(true); - setFfmpegInstallProgress(0); - setFfmpegInstallStatus("starting"); - try { - EventsOn("ffmpeg:progress", (progress: number) => { - setFfmpegInstallProgress(progress); - if (progress >= 100) { - setFfmpegInstallStatus("extracting"); - } - else { - setFfmpegInstallStatus("downloading"); - } - }); - EventsOn("ffmpeg:status", (status: string) => { - setFfmpegInstallStatus(status); - }); - const response = await DownloadFFmpeg(); - EventsOff("ffmpeg:progress"); - EventsOff("ffmpeg:status"); - if (response.success) { - toast.success("FFmpeg installed successfully!"); - setIsFFmpegInstalled(true); - } - else { - toast.error(`Failed to install FFmpeg: ${response.error}`); - } - } - catch (error) { - console.error("Error installing FFmpeg:", error); - toast.error(`Error during FFmpeg installation: ${error}`); - } - finally { - setIsInstallingFFmpeg(false); - setFfmpegInstallProgress(0); - setFfmpegInstallStatus(""); - } - }; - const addToHistory = (item: Omit) => { - setFetchHistory((prev) => { - const normalizedUrl = normalizeHistoryURL(item.url); - const identityKey = getHistoryIdentityKey(item.type, normalizedUrl); - const filtered = prev.filter((h) => getHistoryIdentityKey(h.type, h.url) !== identityKey); - const newItem: HistoryItem = { - ...item, - url: normalizedUrl, - id: crypto.randomUUID(), - timestamp: Date.now(), - }; - const updated = normalizeHistoryItems([newItem, ...filtered]); - void persistRecentHistory(updated); - return updated; - }); - }; - const removeFromHistory = (id: string) => { - setFetchHistory((prev) => { - if (!prev.some((h) => h.id === id)) - return prev; - const updated = prev.filter((h) => h.id !== id); - void persistRecentHistory(updated); - return updated; - }); - }; - const handleHistorySelect = async (item: HistoryItem) => { - setSpotifyUrl(item.url); - const updatedUrl = await metadata.handleFetchMetadata(item.url); - if (updatedUrl) { - setSpotifyUrl(updatedUrl); - } - }; - const handleFetchMetadata = async () => { - const updatedUrl = await metadata.handleFetchMetadata(spotifyUrl); - if (updatedUrl) { - setSpotifyUrl(updatedUrl); - } - }; - useEffect(() => { - if (!metadata.metadata || !spotifyUrl) - return; - let historyItem: Omit | null = null; - if ("track" in metadata.metadata) { - const { track } = metadata.metadata; - historyItem = { - url: spotifyUrl, - type: "track", - name: track.name, - artist: track.artists, - image: track.images, - }; - } - else if ("album_info" in metadata.metadata) { - const { album_info } = metadata.metadata; - historyItem = { - url: spotifyUrl, - type: "album", - name: album_info.name, - artist: `${album_info.total_tracks.toLocaleString()} tracks`, - image: album_info.images, - }; - } - else if ("playlist_info" in metadata.metadata) { - const { playlist_info } = metadata.metadata; - historyItem = { - url: spotifyUrl, - type: "playlist", - name: playlist_info.owner.name, - artist: `${playlist_info.tracks.total.toLocaleString()} tracks`, - image: playlist_info.cover || playlist_info.owner.images || "", - }; - } - else if ("artist_info" in metadata.metadata) { - const { artist_info } = metadata.metadata; - historyItem = { - url: spotifyUrl, - type: "artist", - name: artist_info.name, - artist: `${artist_info.total_albums.toLocaleString()} albums`, - image: artist_info.images, - }; - } - if (historyItem) { - addToHistory(historyItem); - } - }, [metadata.metadata]); - const handleSearchChange = (value: string) => { - setSearchQuery(value); - setCurrentListPage(1); - }; - const toggleTrackSelection = (id: string) => { - setSelectedTracks((prev) => prev.includes(id) ? prev.filter((prevId) => prevId !== id) : [...prev, id]); - }; - const toggleSelectAll = (tracks: any[]) => { - const tracksWithId = tracks.filter((track) => track.spotify_id).map((track) => track.spotify_id || ""); - if (tracksWithId.length === 0) - return; - const allSelected = tracksWithId.every(id => selectedTracks.includes(id)); - if (allSelected) { - setSelectedTracks(prev => prev.filter(id => !tracksWithId.includes(id))); - } - else { - setSelectedTracks(prev => Array.from(new Set([...prev, ...tracksWithId]))); - } - }; - const handleOpenFolder = async () => { - const settings = getSettings(); - if (!settings.downloadPath) { - toast.error("Download path not set"); - return; - } - try { - await OpenFolder(settings.downloadPath); - } - catch (error) { - console.error("Error opening folder:", error); - toast.error(`Error opening folder: ${error}`); - } - }; - const renderMetadata = () => { - if (!metadata.metadata) - return null; - if ("track" in metadata.metadata) { - const { track } = metadata.metadata; - const trackId = track.spotify_id || ""; - return ( 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} onAlbumClick={metadata.handleAlbumClick} onArtistClick={async (artist) => { - const artistUrl = await metadata.handleArtistClick(artist); - if (artistUrl) { - setSpotifyUrl(artistUrl); - } - }} onBack={metadata.resetMetadata}/>); - } - if ("album_info" in metadata.metadata) { - const { album_info, track_list } = metadata.metadata; - return ( lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, album_info.name, position, albumArtist, releaseDate, discNumber, true)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, album_info.name, position, trackId, albumArtist, releaseDate, discNumber, true)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, album_info.name, undefined, true)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, album_info.name, true)} onDownloadAll={() => download.handleDownloadAll(track_list, album_info.name, true)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, album_info.name, true)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onPageChange={setCurrentListPage} onBack={metadata.resetMetadata} onArtistClick={async (artist) => { - const pendingArtistUrl = artist.external_urls.replace(/\/$/, "") + "/discography/all"; - setSpotifyUrl(pendingArtistUrl); - const artistUrl = await metadata.handleArtistClick(artist); - if (artistUrl) { - setSpotifyUrl(artistUrl); - } - }} onTrackClick={async (track) => { - if (track.external_urls) { - setSpotifyUrl(track.external_urls); - await metadata.handleFetchMetadata(track.external_urls); - } - }}/>); - } - if ("playlist_info" in metadata.metadata) { - const { playlist_info, track_list } = metadata.metadata; - const settings = getSettings(); - const playlistFolderName = buildPlaylistFolderName(playlist_info.owner.name, playlist_info.owner.display_name, settings.playlistOwnerFolderName); - return ( lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, playlistFolderName, position, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, playlistFolderName, position, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, playlistFolderName)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, playlistFolderName)} onDownloadAll={() => download.handleDownloadAll(track_list, playlistFolderName)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, playlistFolderName)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onPageChange={setCurrentListPage} onBack={metadata.resetMetadata} onAlbumClick={metadata.handleAlbumClick} onArtistClick={async (artist) => { - const pendingArtistUrl = artist.external_urls.replace(/\/$/, "") + "/discography/all"; - setSpotifyUrl(pendingArtistUrl); - const artistUrl = await metadata.handleArtistClick(artist); - if (artistUrl) { - setSpotifyUrl(artistUrl); - } - }} onTrackClick={async (track) => { - if (track.external_urls) { - setSpotifyUrl(track.external_urls); - await metadata.handleFetchMetadata(track.external_urls); - } - }}/>); - } - if ("artist_info" in metadata.metadata) { - const { artist_info, album_list, track_list } = metadata.metadata; - return ( lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, artist_info.name, position, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, artist_info.name, position, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, artist_info.name)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, artist_info.name)} onDownloadAll={() => download.handleDownloadAll(track_list, artist_info.name)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, artist_info.name)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onAlbumClick={metadata.handleAlbumClick} onBack={metadata.resetMetadata} onArtistClick={async (artist) => { - const pendingArtistUrl = artist.external_urls.replace(/\/$/, "") + "/discography/all"; - setSpotifyUrl(pendingArtistUrl); - const artistUrl = await metadata.handleArtistClick(artist); - if (artistUrl) { - setSpotifyUrl(artistUrl); - } - }} onPageChange={setCurrentListPage} onTrackClick={async (track) => { - if (track.external_urls) { - setSpotifyUrl(track.external_urls); - await metadata.handleFetchMetadata(track.external_urls); - } - }}/>); - } - return null; - }; - const handlePageChange = (page: PageType) => { - if (currentPage === "settings" && hasUnsavedSettings && page !== "settings") { - setPendingPageChange(page); - setShowUnsavedChangesDialog(true); - return; - } - setCurrentPage(page); - }; - const handleDiscardChanges = () => { - setShowUnsavedChangesDialog(false); - if (resetSettingsFn) { - resetSettingsFn(); - } - const savedSettings = getSettings(); - applyThemeMode(savedSettings.themeMode); - applyTheme(savedSettings.theme); - applyFont(savedSettings.fontFamily, savedSettings.customFonts); - if (pendingPageChange) { - setCurrentPage(pendingPageChange); - setPendingPageChange(null); - } - }; - const handleCancelNavigation = () => { - setShowUnsavedChangesDialog(false); - setPendingPageChange(null); - }; - const renderPage = () => { - switch (currentPage) { - case "settings": - return ; - case "debug": - return ; - case "projects": - return ; - case "support": - return ; - case "history": - return { - metadata.loadFromCache(cachedData); - setCurrentPage("main"); - }}/>; - case "audio-analysis": - return ; - case "audio-converter": - return ; - case "audio-resampler": - return ; - case "file-manager": - return ; - default: - return (<> -
- - - - - - -
- -
- Fetch Album - - Do you want to fetch metadata for this album? - - {metadata.selectedAlbum && (
-

{metadata.selectedAlbum.name}

-
)} - - - - -
-
- - { - setSpotifyUrl(url); - const updatedUrl = await metadata.handleFetchMetadata(url); - if (updatedUrl) { - setSpotifyUrl(updatedUrl); - } - }} history={fetchHistory} onHistorySelect={handleHistorySelect} onHistoryRemove={removeFromHistory} hasResult={!!metadata.metadata} searchMode={isSearchMode} onSearchModeChange={setIsSearchMode} region={region} onRegionChange={setRegion}/> - - {!isSearchMode && metadata.metadata && renderMetadata()} - ); - } - }; - return ( -
- - - - -
-
-
- {renderPage()} -
-
-
- - - - - - - - - {showScrollTop && ()} - - - - - - Unsaved Changes - - You have unsaved changes in Settings. Are you sure you want to leave? Your changes will be lost. - - - - - - - - - - - - - Fetch Failed - - - Metadata fetch failed. Try using a high-quality VPN such as - Surfshark, ExpressVPN, Proton VPN, or a similar service. - - - Choose a location that is not blocked by Spotify or the - related service, such as the USA, UK, Germany, Netherlands, - or Singapore. - - - If you are already using a VPN, try switching to another - server and fetch again. - - - - - - - - - - { }}> - - - - FFmpeg Required - - - SpotiFLAC checks your system for FFmpeg and FFprobe first. - If they are not available, the required binaries will be downloaded from GitHub. - This setup downloads about 30-40MB of data. - - - - {isInstallingFFmpeg && (
- {ffmpegInstallStatus === "extracting" ? (
-
-
- Extracting... -
- Finalizing setup -
) : (
-
-
- Downloading... - {downloadProgress.is_downloading && downloadProgress.mb_downloaded > 0 && ( - {downloadProgress.mb_downloaded.toFixed(1)}MB - {downloadProgress.speed_mbps > 0 && ` @ ${downloadProgress.speed_mbps.toFixed(1)}MB/s`} - )} -
- {ffmpegInstallProgress}% -
-
-
-
-
)} -
)} - - - {!isInstallingFFmpeg && ()} - - - -
-
-
); -} -export default App; diff --git a/frontend/src/assets/audiotts-pro.webp b/frontend/src/assets/audiotts-pro.webp deleted file mode 100644 index 9515875..0000000 Binary files a/frontend/src/assets/audiotts-pro.webp and /dev/null differ diff --git a/frontend/src/assets/chatgpt-tts.webp b/frontend/src/assets/chatgpt-tts.webp deleted file mode 100644 index b77385b..0000000 Binary files a/frontend/src/assets/chatgpt-tts.webp and /dev/null differ diff --git a/frontend/src/assets/github-lang-colors.ts b/frontend/src/assets/github-lang-colors.ts deleted file mode 100644 index e390cab..0000000 --- a/frontend/src/assets/github-lang-colors.ts +++ /dev/null @@ -1,18 +0,0 @@ -export const langColors: Record = { - "TypeScript": "#2b7489", - "Go": "#375eab", - "Python": "#3572A5", - "CSS": "#563d7c", - "HTML": "#e44b23", - "JavaScript": "#f1e05a", - "Java": "#b07219", - "C": "#555555", - "C Sharp": "#178600", - "cpp": "#f34b7d", - "Ruby": "#701516", - "PHP": "#4F5D95", - "Swift": "#ffac45", - "Kotlin": "#F18E33", - "Rust": "#dea584", - "Shell": "#89e051" -}; diff --git a/frontend/src/assets/icons/am.png b/frontend/src/assets/icons/am.png deleted file mode 100644 index 5040e0e..0000000 Binary files a/frontend/src/assets/icons/am.png and /dev/null differ diff --git a/frontend/src/assets/icons/amzn.png b/frontend/src/assets/icons/amzn.png deleted file mode 100644 index e138846..0000000 Binary files a/frontend/src/assets/icons/amzn.png and /dev/null differ diff --git a/frontend/src/assets/icons/dzr.png b/frontend/src/assets/icons/dzr.png deleted file mode 100644 index c31e8be..0000000 Binary files a/frontend/src/assets/icons/dzr.png and /dev/null differ diff --git a/frontend/src/assets/icons/lrclib.png b/frontend/src/assets/icons/lrclib.png deleted file mode 100644 index ac3e5e7..0000000 Binary files a/frontend/src/assets/icons/lrclib.png and /dev/null differ diff --git a/frontend/src/assets/icons/musicbrainz_d.png b/frontend/src/assets/icons/musicbrainz_d.png deleted file mode 100644 index 7467b95..0000000 Binary files a/frontend/src/assets/icons/musicbrainz_d.png and /dev/null differ diff --git a/frontend/src/assets/icons/musicbrainz_l.png b/frontend/src/assets/icons/musicbrainz_l.png deleted file mode 100644 index 1b864f7..0000000 Binary files a/frontend/src/assets/icons/musicbrainz_l.png and /dev/null differ diff --git a/frontend/src/assets/icons/next.svg b/frontend/src/assets/icons/next.svg deleted file mode 100644 index 3ac2653..0000000 --- a/frontend/src/assets/icons/next.svg +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/frontend/src/assets/icons/qbz.png b/frontend/src/assets/icons/qbz.png deleted file mode 100644 index a6eb75e..0000000 Binary files a/frontend/src/assets/icons/qbz.png and /dev/null differ diff --git a/frontend/src/assets/icons/songlink_d.png b/frontend/src/assets/icons/songlink_d.png deleted file mode 100644 index b988734..0000000 Binary files a/frontend/src/assets/icons/songlink_d.png and /dev/null differ diff --git a/frontend/src/assets/icons/songlink_l.png b/frontend/src/assets/icons/songlink_l.png deleted file mode 100644 index fd0cb1a..0000000 Binary files a/frontend/src/assets/icons/songlink_l.png and /dev/null differ diff --git a/frontend/src/assets/icons/songstats.png b/frontend/src/assets/icons/songstats.png deleted file mode 100644 index ae111fc..0000000 Binary files a/frontend/src/assets/icons/songstats.png and /dev/null differ diff --git a/frontend/src/assets/icons/spotiflac.svg b/frontend/src/assets/icons/spotiflac.svg deleted file mode 100644 index cd3d7b2..0000000 --- a/frontend/src/assets/icons/spotiflac.svg +++ /dev/null @@ -1,39 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/src/assets/icons/spotubedl.svg b/frontend/src/assets/icons/spotubedl.svg deleted file mode 100644 index 09e2249..0000000 --- a/frontend/src/assets/icons/spotubedl.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/frontend/src/assets/icons/tidal_d.png b/frontend/src/assets/icons/tidal_d.png deleted file mode 100644 index 4760bfa..0000000 Binary files a/frontend/src/assets/icons/tidal_d.png and /dev/null differ diff --git a/frontend/src/assets/icons/tidal_l.png b/frontend/src/assets/icons/tidal_l.png deleted file mode 100644 index 7397386..0000000 Binary files a/frontend/src/assets/icons/tidal_l.png and /dev/null differ diff --git a/frontend/src/assets/icons/xbatchdl.svg b/frontend/src/assets/icons/xbatchdl.svg deleted file mode 100644 index 11c11d5..0000000 --- a/frontend/src/assets/icons/xbatchdl.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - diff --git a/frontend/src/assets/ko-fi.gif b/frontend/src/assets/ko-fi.gif deleted file mode 100644 index 221d0b9..0000000 Binary files a/frontend/src/assets/ko-fi.gif and /dev/null differ diff --git a/frontend/src/assets/kofi_symbol.svg b/frontend/src/assets/kofi_symbol.svg deleted file mode 100644 index ade749d..0000000 --- a/frontend/src/assets/kofi_symbol.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/frontend/src/assets/patreon.svg b/frontend/src/assets/patreon.svg deleted file mode 100644 index 5a0c330..0000000 --- a/frontend/src/assets/patreon.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/frontend/src/assets/patreon_symbol.svg b/frontend/src/assets/patreon_symbol.svg deleted file mode 100644 index f5d2997..0000000 --- a/frontend/src/assets/patreon_symbol.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - diff --git a/frontend/src/assets/usdt.jpg b/frontend/src/assets/usdt.jpg deleted file mode 100644 index 5732bda..0000000 Binary files a/frontend/src/assets/usdt.jpg and /dev/null differ diff --git a/frontend/src/assets/x.webp b/frontend/src/assets/x.webp deleted file mode 100644 index 44059a7..0000000 Binary files a/frontend/src/assets/x.webp and /dev/null differ diff --git a/frontend/src/components/AlbumInfo.tsx b/frontend/src/components/AlbumInfo.tsx deleted file mode 100644 index 6c3d722..0000000 --- a/frontend/src/components/AlbumInfo.tsx +++ /dev/null @@ -1,284 +0,0 @@ -import { Button } from "@/components/ui/button"; -import { Card, CardContent } from "@/components/ui/card"; -import { Download, FolderOpen, ImageDown, FileText, XCircle } from "lucide-react"; -import { Spinner } from "@/components/ui/spinner"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { SearchAndSort } from "./SearchAndSort"; -import { TrackList } from "./TrackList"; -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 { buildClickableArtists, splitArtistNames } from "@/lib/artist-links"; -import type { TrackMetadata, TrackAvailability } from "@/types/api"; -interface AlbumInfoProps { - albumInfo: { - name: string; - artists: string; - images: string; - release_date: string; - total_tracks: number; - artist_id?: string; - artist_url?: string; - }; - trackList: TrackMetadata[]; - searchQuery: string; - sortBy: string; - selectedTracks: string[]; - downloadedTracks: Set; - failedTracks: Set; - skippedTracks: Set; - downloadingTrack: string | null; - isDownloading: boolean; - bulkDownloadType: "all" | "selected" | null; - downloadProgress: number; - downloadRemainingCount: number; - currentDownloadInfo: { - name: string; - artists: string; - } | null; - currentPage: number; - itemsPerPage: number; - downloadedLyrics?: Set; - failedLyrics?: Set; - skippedLyrics?: Set; - downloadingLyricsTrack?: string | null; - checkingAvailabilityTrack?: string | null; - availabilityMap?: Map; - downloadedCovers?: Set; - failedCovers?: Set; - skippedCovers?: Set; - downloadingCoverTrack?: string | null; - isBulkDownloadingCovers?: boolean; - isBulkDownloadingLyrics?: boolean; - isMetadataLoading?: boolean; - onSearchChange: (value: string) => void; - onSortChange: (value: string) => void; - onToggleTrack: (id: string) => void; - onToggleSelectAll: (tracks: TrackMetadata[]) => 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; - 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; - onDownloadAllLyrics?: () => void; - onDownloadAllCovers?: () => void; - onDownloadAll: () => void; - onDownloadSelected: () => void; - onStopDownload: () => void; - onOpenFolder: () => void; - onPageChange: (page: number) => void; - onArtistClick?: (artist: { - id: string; - name: string; - external_urls: string; - }) => void; - onTrackClick?: (track: TrackMetadata) => void; - onBack?: () => void; -} -export function AlbumInfo({ albumInfo, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, downloadRemainingCount, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, isMetadataLoading = false, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onPageChange, onArtistClick, onTrackClick, onBack, }: AlbumInfoProps) { - const settings = getSettings(); - const albumArtistNames = splitArtistNames(albumInfo.artists); - const artistSeparator = albumInfo.artists.includes(";") ? "; " : ", "; - const fetchedTrackCount = trackList.length; - const totalTrackCount = albumInfo.total_tracks; - const showStreamingProgress = isMetadataLoading && totalTrackCount > 0 && fetchedTrackCount < totalTrackCount; - const clickableAlbumArtists = (() => { - const artistsByName = new Map(); - for (const track of trackList) { - const clickableTrackArtists = buildClickableArtists(track.artists, track.artists_data, track.artist_id, track.artist_url); - for (const artist of clickableTrackArtists) { - const normalizedName = artist.name.trim().toLowerCase(); - if (!normalizedName || !artist.external_urls || artistsByName.has(normalizedName)) { - continue; - } - artistsByName.set(normalizedName, artist); - } - } - return albumArtistNames.map((name) => { - const normalizedName = name.trim().toLowerCase(); - const matchedArtist = artistsByName.get(normalizedName); - if (matchedArtist) { - return { - ...matchedArtist, - name, - }; - } - if (albumArtistNames.length === 1 && albumInfo.artist_id && albumInfo.artist_url) { - return { - id: albumInfo.artist_id, - name, - external_urls: albumInfo.artist_url, - }; - } - return { - id: "", - name, - external_urls: "", - }; - }); - })(); - 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 (
- - {onBack && (
- -
)} - -
- {albumInfo.images && (
- {albumInfo.name} -
- - - - -

Download Separate Album Cover

-
-
-
)} -
-
-

Album

-

{albumInfo.name}

-
- - {clickableAlbumArtists.length > 0 ? clickableAlbumArtists.map((artist, index) => ( - {onArtistClick && artist.external_urls ? ( onArtistClick({ - id: artist.id, - name: artist.name, - external_urls: artist.external_urls, - })}> - {artist.name} - ) : (artist.name)} - {index < clickableAlbumArtists.length - 1 && artistSeparator} - )) : albumInfo.artists} - - - {albumInfo.release_date} - - - {showStreamingProgress - ? `${fetchedTrackCount.toLocaleString()} / ${totalTrackCount.toLocaleString()} tracks` - : `${Math.max(totalTrackCount, fetchedTrackCount).toLocaleString()} ${Math.max(totalTrackCount, fetchedTrackCount) === 1 ? "track" : "tracks"}`} - -
-
-
- - {selectedTracks.length > 0 && ()} - {onDownloadAllLyrics && ( - - - - -

Download All Lyrics

-
-
)} - {onDownloadAllCovers && ( - - - - -

Download All Separate Covers

-
-
)} - {downloadedTracks.size > 0 && ( - - - - -

Open Folder

-
-
)} -
- {isDownloading && ()} -
-
-
-
-
- - -
-
); -} diff --git a/frontend/src/components/ApiStatusTab.tsx b/frontend/src/components/ApiStatusTab.tsx deleted file mode 100644 index e451746..0000000 --- a/frontend/src/components/ApiStatusTab.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import { Button } from "@/components/ui/button"; -import { PlugZap, CheckCircle2, Loader2, Wrench } from "lucide-react"; -import { TidalIcon, QobuzIcon, AmazonIcon, AppleMusicIcon, DeezerIcon } from "./PlatformIcons"; -import { useApiStatus } from "@/hooks/useApiStatus"; -import { SPOTIFLAC_NEXT_SOURCES } from "@/lib/api-status"; -function renderStatusIndicator(status: "checking" | "online" | "offline" | "idle") { - if (status === "online") { - return ; - } - if (status === "offline") { - return ; - } - return null; -} -function renderPlatformIcon(type: string) { - if (type === "tidal") { - return ; - } - if (type === "amazon") { - return ; - } - if (type === "deezer") { - return ; - } - if (type === "apple") { - return ; - } - return ; -} -export function ApiStatusTab() { - const { sources, statuses, nextStatuses, checkingSources, checkAllCurrent, checkAllNext } = useApiStatus(); - const isCheckingCurrent = sources.some((source) => checkingSources[source.id] === true); - const isCheckingNext = SPOTIFLAC_NEXT_SOURCES.some((source) => nextStatuses[source.id] === "checking"); - return (
-
-
-

SpotiFLAC

- -
- -
- {sources.map((source) => { - const status = statuses[source.id] || "idle"; - return (
-
-
- {renderPlatformIcon(source.type)} -

{source.name}

-
-
{renderStatusIndicator(status)}
-
-
); - })} -
-
- -
- -
-
-

SpotiFLAC Next

- -
- -
- {SPOTIFLAC_NEXT_SOURCES.map((source) => { - const status = nextStatuses[source.id] || "idle"; - return (
-
- {renderPlatformIcon(source.id)} -

{source.name}

-
-
{renderStatusIndicator(status)}
-
); - })} -
-
-
); -} diff --git a/frontend/src/components/ArtistInfo.tsx b/frontend/src/components/ArtistInfo.tsx deleted file mode 100644 index 8737cbb..0000000 --- a/frontend/src/components/ArtistInfo.tsx +++ /dev/null @@ -1,643 +0,0 @@ -import { Button } from "@/components/ui/button"; -import { Card, CardContent } from "@/components/ui/card"; -import { Download, FolderOpen, ImageDown, FileText, BadgeCheck, XCircle, Filter } from "lucide-react"; -import { Spinner } from "@/components/ui/spinner"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { SearchAndSort } from "./SearchAndSort"; -import { TrackList } from "./TrackList"; -import { DownloadProgress } from "./DownloadProgress"; -import type { TrackMetadata, TrackAvailability } from "@/types/api"; -import { downloadHeader, downloadGalleryImage, downloadAvatar } from "@/lib/api"; -import { getSettings } from "@/lib/settings"; -import { toastWithSound as toast } from "@/lib/toast-with-sound"; -import { useState, useMemo } from "react"; -import { Checkbox } from "@/components/ui/checkbox"; -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; -import { ScrollArea } from "@/components/ui/scroll-area"; -interface ArtistInfoProps { - artistInfo: { - name: string; - images: string; - header?: string; - gallery?: string[]; - followers: number; - total_albums?: number; - genres: string[]; - biography?: string; - verified?: boolean; - listeners?: number; - rank?: number; - }; - albumList: Array<{ - id: string; - name: string; - images: string; - release_date: string; - album_type: string; - external_urls: string; - total_tracks?: number; - }>; - trackList: TrackMetadata[]; - searchQuery: string; - sortBy: string; - selectedTracks: string[]; - downloadedTracks: Set; - failedTracks: Set; - skippedTracks: Set; - downloadingTrack: string | null; - isDownloading: boolean; - bulkDownloadType: "all" | "selected" | null; - downloadProgress: number; - downloadRemainingCount: number; - currentDownloadInfo: { - name: string; - artists: string; - } | null; - currentPage: number; - itemsPerPage: number; - downloadedLyrics?: Set; - failedLyrics?: Set; - skippedLyrics?: Set; - downloadingLyricsTrack?: string | null; - checkingAvailabilityTrack?: string | null; - availabilityMap?: Map; - downloadedCovers?: Set; - failedCovers?: Set; - skippedCovers?: Set; - downloadingCoverTrack?: string | null; - isBulkDownloadingCovers?: boolean; - isBulkDownloadingLyrics?: boolean; - isMetadataLoading?: boolean; - onSearchChange: (value: string) => void; - onSortChange: (value: string) => void; - onToggleTrack: (id: string) => void; - onToggleSelectAll: (tracks: TrackMetadata[]) => 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; - 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; - onDownloadAllLyrics?: () => void; - onDownloadAllCovers?: () => void; - onDownloadAll: () => void; - onDownloadSelected: () => void; - onStopDownload: () => void; - onOpenFolder: () => void; - onAlbumClick: (album: { - id: string; - name: string; - external_urls: string; - }) => void; - onArtistClick: (artist: { - id: string; - name: string; - external_urls: string; - }) => void; - onPageChange: (page: number) => void; - onTrackClick?: (track: TrackMetadata) => void; - onBack?: () => void; -} -export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, downloadRemainingCount, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, isMetadataLoading = false, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onAlbumClick, onArtistClick, onPageChange, onTrackClick, onBack, }: ArtistInfoProps) { - const [downloadingHeader, setDownloadingHeader] = useState(false); - const [downloadingAvatar, setDownloadingAvatar] = useState(false); - const [downloadingGalleryIndex, setDownloadingGalleryIndex] = useState(null); - const [downloadingAllGallery, setDownloadingAllGallery] = useState(false); - const [activeTab, setActiveTab] = useState<"albums" | "tracks" | "gallery">("albums"); - const [activeAlbumFilter, setActiveAlbumFilter] = useState("all"); - const displayedAlbumCount = artistInfo.total_albums || albumList.length; - const fetchedAlbumCount = albumList.length; - const totalAlbumCount = artistInfo.total_albums || fetchedAlbumCount; - const totalTrackCount = albumList.reduce((sum, album) => sum + (album.total_tracks || 0), 0); - const fetchedTrackCount = trackList.length; - const albumCountLabel = isMetadataLoading && totalAlbumCount > 0 && fetchedAlbumCount < totalAlbumCount - ? `${fetchedAlbumCount.toLocaleString()} / ${totalAlbumCount.toLocaleString()} albums` - : `${displayedAlbumCount.toLocaleString()} ${displayedAlbumCount === 1 ? "album" : "albums"}`; - const resolvedTrackCount = totalTrackCount > 0 ? totalTrackCount : fetchedTrackCount; - const trackCountLabel = isMetadataLoading && totalTrackCount > 0 && fetchedTrackCount < totalTrackCount - ? `${fetchedTrackCount.toLocaleString()} / ${totalTrackCount.toLocaleString()} tracks` - : `${resolvedTrackCount.toLocaleString()} ${resolvedTrackCount === 1 ? "track" : "tracks"}`; - const albumFilterCounts = useMemo(() => { - const counts = new Map(); - counts.set("all", (albumList || []).length); - for (const album of albumList || []) { - const type = (album.album_type || "").trim().toLowerCase(); - if (!type) - continue; - counts.set(type, (counts.get(type) || 0) + 1); - } - return counts; - }, [albumList]); - const albumFilters = useMemo(() => { - const uniqueTypes = Array.from(new Set((albumList || []) - .map((album) => (album.album_type || "").trim().toLowerCase()) - .filter(Boolean))); - return ["all", ...uniqueTypes]; - }, [albumList]); - const filteredAlbums = useMemo(() => { - if (activeAlbumFilter === "all") { - return albumList || []; - } - return (albumList || []).filter((album) => (album.album_type || "").trim().toLowerCase() === activeAlbumFilter); - }, [albumList, activeAlbumFilter]); - const filteredAlbumGroups = useMemo(() => { - const albumTypeMap = new Map(albumList.map(a => [a.name, a.album_type])); - const albumGroups = trackList.reduce((acc, track) => { - if (!track.album_name) - return acc; - if (!acc[track.album_name]) { - acc[track.album_name] = { - count: 0, - tracks: [], - type: albumTypeMap.get(track.album_name) || "unknown" - }; - } - acc[track.album_name].count++; - acc[track.album_name].tracks.push(track); - return acc; - }, {} as Record); - return Object.entries(albumGroups).sort((a, b) => { - const dateA = a[1].tracks[0]?.release_date || ""; - const dateB = b[1].tracks[0]?.release_date || ""; - return dateB.localeCompare(dateA); - }); - }, [trackList, albumList]); - const formatAlbumFilterLabel = (value: string) => { - const count = albumFilterCounts.get(value) || 0; - if (value === "all") - return `All (${count})`; - const label = value - .split(/[_\s]+/) - .filter(Boolean) - .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) - .join(" "); - return `${label} (${count})`; - }; - const handleDownloadHeader = async () => { - if (!artistInfo.header) - return; - setDownloadingHeader(true); - try { - const settings = getSettings(); - const response = await downloadHeader({ - header_url: artistInfo.header, - artist_name: artistInfo.name, - output_dir: settings.downloadPath, - }); - if (response.success) { - if (response.already_exists) { - toast.info("Header already exists"); - } - else { - toast.success("Header downloaded successfully"); - } - } - else { - toast.error(response.error || "Failed to download header"); - } - } - catch (error) { - toast.error(`Error downloading header: ${error}`); - } - finally { - setDownloadingHeader(false); - } - }; - const handleDownloadAvatar = async () => { - if (!artistInfo.images) - return; - setDownloadingAvatar(true); - try { - const settings = getSettings(); - const response = await downloadAvatar({ - avatar_url: artistInfo.images, - artist_name: artistInfo.name, - output_dir: settings.downloadPath, - }); - if (response.success) { - if (response.already_exists) { - toast.info("Avatar already exists"); - } - else { - toast.success("Avatar downloaded successfully"); - } - } - else { - toast.error(response.error || "Failed to download avatar"); - } - } - catch (error) { - toast.error(`Error downloading avatar: ${error}`); - } - finally { - setDownloadingAvatar(false); - } - }; - const handleDownloadGalleryImage = async (imageUrl: string, index: number) => { - setDownloadingGalleryIndex(index); - try { - const settings = getSettings(); - const response = await downloadGalleryImage({ - image_url: imageUrl, - artist_name: artistInfo.name, - image_index: index, - output_dir: settings.downloadPath, - }); - if (response.success) { - if (response.already_exists) { - toast.info(`Gallery image ${index + 1} already exists`); - } - else { - toast.success(`Gallery image ${index + 1} downloaded successfully`); - } - } - else { - toast.error(response.error || `Failed to download gallery image ${index + 1}`); - } - } - catch (error) { - toast.error(`Error downloading gallery image ${index + 1}: ${error}`); - } - finally { - setDownloadingGalleryIndex(null); - } - }; - const handleDownloadAllGallery = async () => { - if (!artistInfo.gallery || artistInfo.gallery.length === 0) - return; - setDownloadingAllGallery(true); - try { - const settings = getSettings(); - let successCount = 0; - let existsCount = 0; - let failCount = 0; - for (let index = 0; index < artistInfo.gallery.length; index++) { - const imageUrl = artistInfo.gallery[index]; - try { - const response = await downloadGalleryImage({ - image_url: imageUrl, - artist_name: artistInfo.name, - image_index: index, - output_dir: settings.downloadPath, - }); - if (response.success) { - if (response.already_exists) { - existsCount++; - } - else { - successCount++; - } - } - else { - failCount++; - } - } - catch (error) { - failCount++; - } - } - if (failCount === 0) { - if (existsCount > 0 && successCount > 0) { - toast.success(`${successCount} images downloaded, ${existsCount} already existed`); - } - else if (existsCount > 0) { - toast.info(`All ${existsCount} images already exist`); - } - else { - toast.success(`All ${successCount} gallery images downloaded successfully`); - } - } - else { - toast.error(`${failCount} images failed to download`); - } - } - catch (error) { - toast.error(`Error downloading gallery images: ${error}`); - } - finally { - setDownloadingAllGallery(false); - } - }; - const hasGallery = artistInfo.gallery && artistInfo.gallery.length > 0; - return (
- - {artistInfo.header ? (<> -
-
-
- {onBack && (
- -
)} -
- - - - - -

Download Header

-
-
-
-
-
- {artistInfo.images && (
- {artistInfo.name} -
- - - - - -

Download Avatar

-
-
-
-
)} -
-

Artist

-
-

{artistInfo.name}

- {artistInfo.verified && ()} -
- {artistInfo.biography && (

{artistInfo.biography}

)} -
- {artistInfo.rank && (<> - #{artistInfo.rank} rank - - )} - {artistInfo.followers.toLocaleString()} {artistInfo.followers === 1 ? "follower" : "followers"} - {artistInfo.listeners && (<> - - {artistInfo.listeners.toLocaleString()} {artistInfo.listeners === 1 ? "listener" : "listeners"} - )} -
-
- {albumCountLabel} - - {trackCountLabel} - {artistInfo.genres.length > 0 && (<> - - {artistInfo.genres.join(", ")} - )} -
-
-
-
-
- ) : ( - {onBack && (
- -
)} -
- {artistInfo.images && (
- {artistInfo.name} -
- - - - - -

Download Avatar

-
-
-
-
)} -
-

Artist

-
-

{artistInfo.name}

- {artistInfo.verified && ()} -
- {artistInfo.biography && (

{artistInfo.biography}

)} -
- {artistInfo.rank && (<> - #{artistInfo.rank} rank - - )} - {artistInfo.followers.toLocaleString()} {artistInfo.followers === 1 ? "follower" : "followers"} - {artistInfo.listeners && (<> - - {artistInfo.listeners.toLocaleString()} {artistInfo.listeners === 1 ? "listener" : "listeners"} - )} -
-
- {albumCountLabel} - - {trackCountLabel} - {artistInfo.genres.length > 0 && (<> - - {artistInfo.genres.join(", ")} - )} -
-
-
-
)} - - -
-
- - - {hasGallery && ()} -
-
- - {activeTab === "gallery" && hasGallery && (
-
-

Gallery ({artistInfo.gallery!.length.toLocaleString()})

- - - - - -

Download All Gallery

-
-
-
-
- {artistInfo.gallery!.map((imageUrl, index) => (
-
- {`${artistInfo.name} -
- - - - - -

Download Image {index + 1}

-
-
-
-
-
))} -
-
)} - - {activeTab === "albums" && albumList.length > 0 && (
-
-

Discography

-
- - {selectedTracks.length > 0 && ()} -
-
- {albumFilters.length > 1 && (
- {albumFilters.map((filter) => ())} -
)} -
- {filteredAlbums.map((album) => { - const albumTracks = trackList.filter(t => t.album_name === album.name); - const tracksWithId = albumTracks.filter(t => t.spotify_id); - const isSelected = tracksWithId.length > 0 && tracksWithId.every(t => selectedTracks.includes(t.spotify_id!)); - const hasTracks = tracksWithId.length > 0; - return (
onAlbumClick({ - id: album.id, - name: album.name, - external_urls: album.external_urls, - })}> -
- - {hasTracks && (
e.stopPropagation()}> - onToggleSelectAll(albumTracks)} className="bg-black/50 border-white/70 data-[state=checked]:bg-primary data-[state=checked]:border-primary"/> -
)} - {album.images && ({album.name})} -
- - {album.album_type} - -
-
-

{album.name}

-
- {album.release_date?.split("-")[0]} - {album.total_tracks && (<> - - {album.total_tracks} {album.total_tracks === 1 ? "track" : "tracks"} - )} -
-
); - })} -
- {filteredAlbums.length === 0 && (
- No releases found for the selected discography filter. -
)} -
)} - - {activeTab === "tracks" && trackList.length > 0 && (
-
-

All Tracks

-
- - - - - - - Select Albums - - -
- {filteredAlbumGroups.map(([albumName, data]) => { - const tracksWithId = data.tracks.filter(t => t.spotify_id); - const isSelected = tracksWithId.length > 0 && tracksWithId.every(t => selectedTracks.includes(t.spotify_id!)); - return (
- onToggleSelectAll(data.tracks)} className="mt-1"/> -
- -
- - {data.type} - - - {data.count} tracks - - {data.tracks[0]?.release_date?.split('-')[0] || 'Unknown Year'} -
-
-
); - })} -
-
-
-
- - {selectedTracks.length > 0 && ()} - {onDownloadAllLyrics && ( - - - - -

Download All Lyrics

-
-
)} - {onDownloadAllCovers && ( - - - - -

Download All Separate Covers

-
-
)} - {downloadedTracks.size > 0 && ( - - - - -

Open Folder

-
-
)} -
-
- {isDownloading && ()} - - -
)} -
); -} diff --git a/frontend/src/components/AudioAnalysis.tsx b/frontend/src/components/AudioAnalysis.tsx deleted file mode 100644 index e2be418..0000000 --- a/frontend/src/components/AudioAnalysis.tsx +++ /dev/null @@ -1,179 +0,0 @@ -import { Card, CardContent, CardHeader } from "@/components/ui/card"; -import { Spinner } from "@/components/ui/spinner"; -import { Button } from "@/components/ui/button"; -import { Activity } from "lucide-react"; -import type { AnalysisResult } from "@/types/api"; -interface AudioAnalysisProps { - result: AnalysisResult | null; - analyzing: boolean; - onAnalyze?: () => void; - showAnalyzeButton?: boolean; - filePath?: string; -} -export function AudioAnalysis({ result, analyzing, onAnalyze, showAnalyzeButton = true, filePath }: AudioAnalysisProps) { - if (analyzing) { - return ( - -
- - Analyzing audio quality... -
-
-
); - } - if (!result && showAnalyzeButton) { - return ( - -
- -
-

Audio Quality Analysis

-

- Inspect spectral content and effective quality of FLAC, MP3, M4A, and AAC files -

-
- {onAnalyze && ()} -
-
-
); - } - if (!result) { - return null; - } - const formatDuration = (seconds: number) => { - const mins = Math.floor(seconds / 60); - const secs = Math.floor(seconds % 60); - return `${mins}:${secs.toString().padStart(2, "0")}`; - }; - const formatNumber = (num: number) => { - return num.toFixed(2); - }; - const 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]; - }; - 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 ( - - {filePath && (

{filePath}

)} -
- - -
-
-

Format

-
    - {result.file_type && (
  • - Type: - {result.file_type} -
  • )} -
  • - Sample Rate: - {(result.sample_rate / 1000).toFixed(1)} kHz -
  • -
  • - Bit Depth: - {result.bit_depth} -
  • -
  • - Channels: - {result.channels === 2 ? "Stereo" : result.channels === 1 ? "Mono" : `${result.channels}`} -
  • -
  • - Duration: - {formatDuration(result.duration)} -
  • - {result.file_size > 0 && (
  • - Size: - {formatFileSize(result.file_size)} -
  • )} -
-
- -
-

Signal Analytics

-
    -
  • - Nyquist: - {(nyquistFreq / 1000).toFixed(1)} kHz -
  • -
  • - Dynamic Range: - {formatNumber(result.dynamic_range)} dB -
  • -
  • - Peak Amplitude: - {formatNumber(result.peak_amplitude)} dB -
  • -
  • - RMS Level: - {formatNumber(result.rms_level)} dB -
  • -
  • - Total Samples: - {totalSamplesText} -
  • -
-
- - {hasCodecMeta && (
-

MP3 Meta

-
    - {result.codec_mode && (
  • - Mode: - {result.codec_mode} -
  • )} - {typeof result.bitrate_kbps === "number" && (
  • - Bitrate: - {result.bitrate_kbps} kbps -
  • )} - {typeof result.total_frames === "number" && result.total_frames > 0 && (
  • - Frames: - {result.total_frames.toLocaleString()} -
  • )} - {result.codec_version && (
  • - Version: - {result.codec_version} -
  • )} -
-
)} - - {result.spectrum && (() => { - const frames = result.spectrum.time_slices.length; - const fftSize = (result.spectrum.freq_bins - 1) * 2; - const freqRes = result.sample_rate / fftSize; - return (
-

Spectrum Meta

-
    -
  • - Display Frames: - {frames.toLocaleString()} -
  • -
  • - FFT Size: - {fftSize.toLocaleString()} -
  • -
  • - {freqResolutionLabel} - {freqRes.toFixed(2)} Hz/bin -
  • -
-
); - })()} -
-
-
); -} diff --git a/frontend/src/components/AudioAnalysisPage.tsx b/frontend/src/components/AudioAnalysisPage.tsx deleted file mode 100644 index b31badf..0000000 --- a/frontend/src/components/AudioAnalysisPage.tsx +++ /dev/null @@ -1,884 +0,0 @@ -import { useState, useCallback, useRef, useEffect, type ChangeEvent, type CSSProperties, type DragEvent } from "react"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; -import { Progress } from "@/components/ui/progress"; -import { Spinner } from "@/components/ui/spinner"; -import { Upload, ArrowLeft, Trash2, Download, FolderOpen, X, AlertCircle, CheckCircle2, FileMusic, ChevronDown, Play, StopCircle } from "lucide-react"; -import { AudioAnalysis } from "@/components/AudioAnalysis"; -import { SpectrumVisualization, createSpectrogramDataURL, type SpectrumVisualizationHandle } from "@/components/SpectrumVisualization"; -import { useAudioAnalysis } from "@/hooks/useAudioAnalysis"; -import type { AnalysisResult } from "@/types/api"; -import { loadAudioAnalysisPreferences } from "@/lib/audio-analysis-preferences"; -import { toastWithSound as toast } from "@/lib/toast-with-sound"; -import { GetFileSizes, ListAudioFilesInDir, SaveSpectrumImage, SelectAudioFiles, SelectFolder } from "../../wailsjs/go/main/App"; -import { OnFileDrop, OnFileDropOff } from "../../wailsjs/runtime/runtime"; -interface AudioAnalysisPageProps { - onBack?: () => void; -} -type BatchItemStatus = "pending" | "analyzing" | "success" | "error"; -type BatchItemSource = "path" | "browser"; -interface BatchAnalysisItem { - id: string; - source: BatchItemSource; - path: string; - name: string; - size: number; - status: BatchItemStatus; - error?: string; - result?: AnalysisResult; - file?: File; -} -interface QueueProgressState { - completed: number; - total: number; - fileName: string; -} -const EMPTY_PROGRESS_STATE: QueueProgressState = { - completed: 0, - total: 0, - fileName: "", -}; -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; -} -function browserFileId(file: File): string { - return `browser:${file.name}:${file.size}:${file.lastModified}`; -} -function downloadDataURL(dataUrl: string, fileName: string): void { - const link = document.createElement("a"); - link.href = dataUrl; - link.download = fileName; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); -} -function formatFileSize(bytes: number): string { - if (bytes <= 0) { - return "0 B"; - } - const k = 1024; - const sizes = ["B", "KB", "MB", "GB"]; - const index = Math.min(sizes.length - 1, Math.floor(Math.log(bytes) / Math.log(k))); - return `${parseFloat((bytes / Math.pow(k, index)).toFixed(1))} ${sizes[index]}`; -} -function formatDuration(seconds: number): string { - const mins = Math.floor(seconds / 60); - const secs = Math.floor(seconds % 60); - return `${mins}:${secs.toString().padStart(2, "0")}`; -} -function itemMetaLine(item: BatchAnalysisItem): string { - if (item.result) { - const parts = [ - item.result.file_type ?? "Audio", - `${(item.result.sample_rate / 1000).toFixed(1)} kHz`, - formatDuration(item.result.duration), - ]; - if (typeof item.result.bitrate_kbps === "number" && item.result.bitrate_kbps > 0) { - parts.push(`${item.result.bitrate_kbps} kbps`); - } - return parts.join(" • "); - } - switch (item.status) { - case "analyzing": - return "Analyzing audio quality..."; - case "error": - return item.error || "Analysis failed"; - case "pending": - default: - return "Waiting to be analyzed"; - } -} -function statusIcon(status: BatchItemStatus) { - switch (status) { - case "analyzing": - return ; - case "success": - return ; - case "error": - return ; - case "pending": - default: - return ; - } -} -export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) { - const { analysisProgress, spectrumLoading, spectrumProgress, analyzeFile, analyzeFilePath, cancelAnalysis, loadStoredAnalysis, clearStoredAnalysis, reAnalyzeSpectrum, clearResult, } = useAudioAnalysis(); - const [items, setItems] = useState([]); - const [activeItemId, setActiveItemId] = useState(null); - const [isDragging, setIsDragging] = useState(false); - const [isExportingSelected, setIsExportingSelected] = useState(false); - const [isExportingBatch, setIsExportingBatch] = useState(false); - const [isBatchRunning, setIsBatchRunning] = useState(false); - const [batchProgress, setBatchProgress] = useState(EMPTY_PROGRESS_STATE); - const [exportProgress, setExportProgress] = useState(EMPTY_PROGRESS_STATE); - const fileInputRef = useRef(null); - const spectrumRef = useRef(null); - const batchRunIdRef = useRef(0); - const itemsRef = useRef(items); - const activeItemIdRef = useRef(activeItemId); - useEffect(() => { - itemsRef.current = items; - }, [items]); - useEffect(() => { - activeItemIdRef.current = activeItemId; - }, [activeItemId]); - const setActiveSelection = useCallback((nextId: string | null) => { - activeItemIdRef.current = nextId; - setActiveItemId(nextId); - }, []); - const activeItem = items.find((item) => item.id === activeItemId) ?? null; - const successItems = items.filter((item) => item.status === "success" && item.result?.spectrum); - const pendingItems = items.filter((item) => item.status === "pending"); - const isSingleMode = items.length === 1; - const isBatchMode = items.length > 1; - const canResumeBatch = isBatchMode && !isBatchRunning && pendingItems.length > 0; - const batchPercent = batchProgress.total > 0 - ? Math.round(Math.max(0, Math.min(100, ((batchProgress.completed + (isBatchRunning ? analysisProgress.percent / 100 : 0)) / batchProgress.total) * 100))) - : 0; - const exportPercent = exportProgress.total > 0 - ? Math.round(Math.max(0, Math.min(100, (exportProgress.completed / exportProgress.total) * 100))) - : 0; - useEffect(() => { - if (!activeItem?.result) { - return; - } - loadStoredAnalysis(activeItem.id, activeItem.result, activeItem.path); - }, [activeItem, loadStoredAnalysis]); - const runBatchAnalysis = useCallback(async (entries: BatchAnalysisItem[]) => { - if (entries.length === 0) { - return; - } - const runId = batchRunIdRef.current + 1; - batchRunIdRef.current = runId; - setIsBatchRunning(true); - setBatchProgress({ - completed: 0, - total: entries.length, - fileName: entries[0]?.name ?? "", - }); - let successCount = 0; - let failCount = 0; - try { - for (let index = 0; index < entries.length; index++) { - if (batchRunIdRef.current !== runId) { - return; - } - const entry = entries[index]; - setBatchProgress({ - completed: index, - total: entries.length, - fileName: entry.name, - }); - setItems((prev) => prev.map((item) => item.id === entry.id - ? { ...item, status: "analyzing", error: undefined } - : item)); - const outcome = entry.source === "browser" && entry.file - ? await analyzeFile(entry.file, { - analysisKey: entry.id, - displayPath: entry.path, - suppressToast: true, - }) - : await analyzeFilePath(entry.path, { - analysisKey: entry.id, - displayPath: entry.path, - suppressToast: true, - }); - if (batchRunIdRef.current !== runId) { - return; - } - if (outcome.cancelled) { - return; - } - if (outcome.result) { - const analysisResult = outcome.result; - successCount++; - setItems((prev) => prev.map((item) => item.id === entry.id - ? { - ...item, - status: "success", - error: undefined, - result: analysisResult, - size: analysisResult.file_size || item.size, - } - : item)); - const hasSelectedSuccess = itemsRef.current.some((item) => item.id === activeItemIdRef.current && item.status === "success" && item.result); - if (!hasSelectedSuccess) { - setActiveSelection(entry.id); - } - } - else { - failCount++; - setItems((prev) => prev.map((item) => item.id === entry.id - ? { - ...item, - status: "error", - error: outcome.error || "Analysis failed", - } - : item)); - if (!activeItemIdRef.current) { - setActiveSelection(entry.id); - } - } - } - if (batchRunIdRef.current === runId) { - setBatchProgress({ - completed: entries.length, - total: entries.length, - fileName: "", - }); - if (successCount > 0) { - toast.success("Batch Analysis Complete", { - description: `Successfully analyzed ${successCount} file(s)${failCount > 0 ? `, ${failCount} failed` : ""}`, - }); - } - else if (failCount > 0) { - toast.error("Batch Analysis Failed", { - description: `All ${failCount} file(s) failed to analyze`, - }); - } - } - } - finally { - if (batchRunIdRef.current === runId) { - setIsBatchRunning(false); - } - } - }, [analyzeFile, analyzeFilePath, setActiveSelection]); - const ensureIdleQueue = useCallback(() => { - if (!isBatchRunning) { - return true; - } - toast.info("Analysis in progress", { - description: "Please wait for the current batch to finish or clear it first.", - }); - return false; - }, [isBatchRunning]); - const addPathItems = useCallback(async (paths: string[]) => { - if (!ensureIdleQueue()) { - return; - } - const uniquePaths = Array.from(new Set(paths.filter(Boolean))); - const invalidCount = uniquePaths.filter((path) => !isSupportedAudioPath(path)).length; - const validPaths = uniquePaths.filter(isSupportedAudioPath); - if (invalidCount > 0) { - toast.error("Unsupported format", { - description: `Only ${SUPPORTED_AUDIO_LABEL} files can be analyzed.`, - }); - } - if (validPaths.length === 0) { - return; - } - const existingIds = new Set(itemsRef.current.map((item) => item.id)); - const newPaths = validPaths.filter((path) => !existingIds.has(path)); - if (newPaths.length === 0) { - toast.info("No new files added", { - description: "All selected files were already in the batch queue.", - }); - return; - } - const fileSizes = await GetFileSizes(newPaths); - const newItems = newPaths.map((path) => ({ - id: path, - source: "path" as const, - path, - name: fileNameFromPath(path), - size: fileSizes[path] || 0, - status: "pending" as const, - })); - if (validPaths.length !== newPaths.length) { - toast.info("Some files skipped", { - description: `${validPaths.length - newPaths.length} file(s) were already queued.`, - }); - } - setItems((prev) => [...prev, ...newItems]); - if (!activeItemIdRef.current) { - setActiveSelection(newItems[0]?.id ?? null); - } - void runBatchAnalysis(newItems); - }, [ensureIdleQueue, runBatchAnalysis, setActiveSelection]); - const addBrowserFiles = useCallback(async (files: File[]) => { - if (!ensureIdleQueue()) { - return; - } - const validFiles = files.filter(isSupportedAudioFile); - const invalidCount = files.length - validFiles.length; - if (invalidCount > 0) { - toast.error("Unsupported format", { - description: `Only ${SUPPORTED_AUDIO_LABEL} files can be analyzed.`, - }); - } - if (validFiles.length === 0) { - return; - } - const existingIds = new Set(itemsRef.current.map((item) => item.id)); - const newItems = validFiles - .map((file) => ({ - id: browserFileId(file), - source: "browser" as const, - path: file.name, - name: file.name, - size: file.size, - status: "pending" as const, - file, - })) - .filter((item) => !existingIds.has(item.id)); - if (newItems.length === 0) { - toast.info("No new files added", { - description: "All selected files were already in the batch queue.", - }); - return; - } - if (validFiles.length !== newItems.length) { - toast.info("Some files skipped", { - description: `${validFiles.length - newItems.length} file(s) were already queued.`, - }); - } - setItems((prev) => [...prev, ...newItems]); - if (!activeItemIdRef.current) { - setActiveSelection(newItems[0]?.id ?? null); - } - void runBatchAnalysis(newItems); - }, [ensureIdleQueue, runBatchAnalysis, setActiveSelection]); - const handleSelectFiles = useCallback(async () => { - if (!ensureIdleQueue()) { - return; - } - try { - const selectedPaths = await SelectAudioFiles(); - if (selectedPaths && selectedPaths.length > 0) { - await addPathItems(selectedPaths); - } - return; - } - catch { - fileInputRef.current?.click(); - return; - } - }, [addPathItems, ensureIdleQueue]); - const handleSelectFolder = useCallback(async () => { - if (!ensureIdleQueue()) { - return; - } - try { - const selectedFolder = await SelectFolder(""); - if (!selectedFolder) { - return; - } - const folderFiles = await ListAudioFilesInDir(selectedFolder); - if (!folderFiles || folderFiles.length === 0) { - toast.info("No audio files found", { - description: `No ${SUPPORTED_AUDIO_LABEL} files were found in the selected folder.`, - }); - return; - } - await addPathItems(folderFiles.map((file) => file.path)); - } - catch (err) { - toast.error("Folder Selection Failed", { - description: err instanceof Error ? err.message : "Failed to select folder", - }); - } - }, [addPathItems, ensureIdleQueue]); - const handleInputChange = useCallback(async (event: ChangeEvent) => { - const files = Array.from(event.target.files ?? []); - event.target.value = ""; - if (files.length === 0) { - return; - } - await addBrowserFiles(files); - }, [addBrowserFiles]); - const handleHtmlDrop = useCallback(async (event: DragEvent) => { - event.preventDefault(); - setIsDragging(false); - const files = Array.from(event.dataTransfer.files ?? []); - if (files.length === 0) { - return; - } - await addBrowserFiles(files); - }, [addBrowserFiles]); - useEffect(() => { - OnFileDrop((_x, _y, paths) => { - setIsDragging(false); - if (!paths || paths.length === 0) { - return; - } - void addPathItems(paths); - }, true); - return () => { - OnFileDropOff(); - }; - }, [addPathItems]); - const handleSelectItem = useCallback((itemId: string) => { - setActiveSelection(itemId); - }, [setActiveSelection]); - const handleRemoveItem = useCallback((itemId: string) => { - if (isBatchRunning || isExportingBatch || isExportingSelected || spectrumLoading) { - return; - } - clearStoredAnalysis(itemId); - const nextItems = itemsRef.current.filter((item) => item.id !== itemId); - itemsRef.current = nextItems; - setItems(nextItems); - if (activeItemIdRef.current === itemId) { - const nextActive = nextItems.find((item) => item.status === "success" && item.result) ?? nextItems[0] ?? null; - setActiveSelection(nextActive?.id ?? null); - if (!nextActive) { - clearResult(); - } - } - }, [clearResult, clearStoredAnalysis, isBatchRunning, isExportingBatch, isExportingSelected, setActiveSelection, spectrumLoading]); - const handleClearAll = useCallback(() => { - if (isExportingBatch || isExportingSelected) { - return; - } - batchRunIdRef.current += 1; - itemsRef.current = []; - setItems([]); - setActiveSelection(null); - clearStoredAnalysis(); - clearResult(); - setIsBatchRunning(false); - setBatchProgress(EMPTY_PROGRESS_STATE); - setExportProgress(EMPTY_PROGRESS_STATE); - setIsDragging(false); - }, [clearResult, clearStoredAnalysis, isExportingBatch, isExportingSelected, setActiveSelection]); - const handleStopBatch = useCallback(() => { - if (!isBatchRunning) { - return; - } - batchRunIdRef.current += 1; - cancelAnalysis(); - setIsBatchRunning(false); - setBatchProgress(EMPTY_PROGRESS_STATE); - setItems((prev) => prev.map((item) => item.status === "analyzing" - ? { - ...item, - status: "pending", - } - : item)); - toast.info("Batch analysis stopped", { - description: "Click Analyze to continue the remaining files.", - }); - }, [cancelAnalysis, isBatchRunning]); - const handleAnalyzePending = useCallback(() => { - if (isBatchRunning || isExportingBatch || isExportingSelected || spectrumLoading) { - return; - } - const nextPendingItems = itemsRef.current.filter((item) => item.status === "pending"); - if (nextPendingItems.length === 0) { - return; - } - void runBatchAnalysis(nextPendingItems); - }, [isBatchRunning, isExportingBatch, isExportingSelected, runBatchAnalysis, spectrumLoading]); - const handleExportSelected = useCallback(async () => { - if (!activeItem?.result?.spectrum || !spectrumRef.current) { - return; - } - const dataUrl = spectrumRef.current.getCanvasDataURL(); - if (!dataUrl) { - toast.error("Export Failed", { - description: "Cannot get canvas data", - }); - return; - } - setIsExportingSelected(true); - try { - if (activeItem.source === "path" && isAbsolutePath(activeItem.path)) { - const outPath = await SaveSpectrumImage(activeItem.path, dataUrl); - toast.success("PNG Exported", { - description: `Saved to: ${outPath}`, - }); - return; - } - const baseName = activeItem.name.replace(/\.[^/.]+$/, "") || "spectrogram"; - downloadDataURL(dataUrl, `${baseName}_spectrogram.png`); - toast.success("PNG Exported", { - description: "Spectrogram image downloaded", - }); - } - catch (err) { - toast.error("Export Failed", { - description: err instanceof Error ? err.message : "Failed to export image", - }); - } - finally { - setIsExportingSelected(false); - } - }, [activeItem]); - const handleBatchExport = useCallback(async () => { - const exportableItems = itemsRef.current.filter((item) => item.status === "success" && item.result?.spectrum); - if (exportableItems.length === 0) { - toast.error("Nothing to export", { - description: "Analyze at least one file successfully before exporting PNGs.", - }); - return; - } - const preferences = loadAudioAnalysisPreferences(); - setIsExportingBatch(true); - setExportProgress({ - completed: 0, - total: exportableItems.length, - fileName: exportableItems[0]?.name ?? "", - }); - let successCount = 0; - let failCount = 0; - try { - for (let index = 0; index < exportableItems.length; index++) { - const item = exportableItems[index]; - const result = item.result; - if (!result?.spectrum) { - failCount++; - continue; - } - setExportProgress({ - completed: index, - total: exportableItems.length, - fileName: item.name, - }); - try { - const dataUrl = await createSpectrogramDataURL({ - spectrumData: result.spectrum, - sampleRate: result.sample_rate, - duration: result.duration, - freqScale: preferences.freqScale, - colorScheme: preferences.colorScheme, - fileName: item.name, - }); - if (item.source === "path" && isAbsolutePath(item.path)) { - await SaveSpectrumImage(item.path, dataUrl); - } - else { - const baseName = item.name.replace(/\.[^/.]+$/, "") || "spectrogram"; - downloadDataURL(dataUrl, `${baseName}_spectrogram.png`); - } - successCount++; - } - catch { - failCount++; - } - await new Promise((resolve) => setTimeout(resolve, 0)); - } - setExportProgress({ - completed: exportableItems.length, - total: exportableItems.length, - fileName: "", - }); - if (successCount > 0) { - toast.success("Batch PNG Export Complete", { - description: `Exported ${successCount} spectrogram PNG file(s)${failCount > 0 ? `, ${failCount} failed` : ""}`, - }); - } - else { - toast.error("Batch PNG Export Failed", { - description: "No spectrogram PNG files were exported.", - }); - } - } - finally { - setIsExportingBatch(false); - } - }, []); - const handleReAnalyzeSelectedSpectrum = useCallback(async (fftSize: number, windowFunction: string) => { - if (!activeItem?.result) { - return; - } - const nextResult = await reAnalyzeSpectrum(fftSize, windowFunction); - if (!nextResult) { - return; - } - setItems((prev) => prev.map((item) => item.id === activeItem.id - ? { - ...item, - result: nextResult, - status: "success", - error: undefined, - } - : item)); - }, [activeItem, reAnalyzeSpectrum]); - const batchDetailContent = !activeItem ? ( - -

- Select a file from the batch queue to inspect its analysis result. -

-
-
) : activeItem.status !== "success" || !activeItem.result ? ( - - {activeItem.name} -

{activeItem.path}

-
- - {activeItem.status === "analyzing" && (
-
- - Analyzing audio quality... -
- -

{analysisProgress.message}

-
)} - {activeItem.status === "pending" && (

- This file is queued and waiting for batch analysis to start. -

)} - {activeItem.status === "error" && (
- {activeItem.error || "Analysis failed"} -
)} -
-
) : (
- - - -
); - const singleModeContent = !activeItem ? null : activeItem.status === "success" && activeItem.result ? (
- - - -
) : activeItem.status === "analyzing" || activeItem.status === "pending" ? (
-
-
- {activeItem.status === "pending" ? "Preparing..." : "Processing..."} - {analysisProgress.percent}% -
- -

{analysisProgress.message}

-
-
) : (
-
- {activeItem.error || "Analysis failed"} -
-
); - const showSingleModeActions = isSingleMode && activeItem?.status === "success" && activeItem.result; - return (
- - -
-
- {onBack && ()} -

Audio Quality Analyzer

-
- -
- {isBatchMode && isBatchRunning && ()} - {canResumeBatch && ()} - {isBatchMode && ( - - - - - - - Add Files - - - - Add Folder - - - )} - {showSingleModeActions && ()} - {isBatchMode && ( - - - - - - - Export Selected PNG - - - - Export All PNG - - - )} - {showSingleModeActions && ()} - {isBatchMode && ()} -
-
- - {items.length === 0 && (
{ - event.preventDefault(); - setIsDragging(true); - }} onDragLeave={(event) => { - event.preventDefault(); - setIsDragging(false); - }} onDrop={handleHtmlDrop} style={{ "--wails-drop-target": "drop" } as CSSProperties}> -
- -
-

- {isDragging - ? "Drop your audio files here" - : "Drag and drop audio files here, or click the button below to select"} -

-
- - -
-

- Supported formats: FLAC, MP3, M4A, AAC -

-
)} - - {isSingleMode && (
- {singleModeContent} -
)} - - {isBatchMode && (
-
- {(isBatchRunning || isExportingBatch) && ( - - - {isExportingBatch ? "Batch PNG Export" : "Batch Analysis"} - - - -
- - {isExportingBatch - ? exportProgress.fileName || "Preparing export..." - : batchProgress.fileName || analysisProgress.message} - - - {isExportingBatch - ? `${exportProgress.completed}/${exportProgress.total}` - : `${Math.min(batchProgress.completed + (isBatchRunning ? 1 : 0), batchProgress.total)}/${batchProgress.total}`} - -
- - {!isExportingBatch && (
- {analysisProgress.message} - {analysisProgress.percent}% -
)} -
-
)} - - - -
- Batch Queue -

- {items.length} queued • {successItems.length} ready -

-
-
- -
- {items.map((item) => { - const isActive = item.id === activeItemId; - const isSelectable = item.status !== "pending"; - return (
{ - if (!isSelectable) { - return; - } - handleSelectItem(item.id); - }} onKeyDown={(event) => { - if (!isSelectable) { - return; - } - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - handleSelectItem(item.id); - } - }}> -
{statusIcon(item.status)}
-
-

{item.name}

-

- {itemMetaLine(item)} -

-
- {formatFileSize(item.size)} - {fileNameFromPath(item.path).split(".").pop()?.toUpperCase() || "AUDIO"} -
-
- -
); - })} -
-
-
-
- -
- {batchDetailContent} -
-
)} -
); -} diff --git a/frontend/src/components/AudioConverterPage.tsx b/frontend/src/components/AudioConverterPage.tsx deleted file mode 100644 index 8c2a4f2..0000000 --- a/frontend/src/components/AudioConverterPage.tsx +++ /dev/null @@ -1,460 +0,0 @@ -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, WandSparkles, } from "lucide-react"; -import { Spinner } from "@/components/ui/spinner"; -import { ConvertAudio, SelectAudioFiles, SelectFolder, ListAudioFilesInDir, } from "../../wailsjs/go/main/App"; -import { toastWithSound as toast } from "@/lib/toast-with-sound"; -import { OnFileDrop, OnFileDropOff } from "../../wailsjs/runtime/runtime"; -interface AudioFile { - path: string; - name: string; - format: string; - size: number; - status: "pending" | "converting" | "success" | "error"; - error?: string; - outputPath?: string; -} -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]; -} -const BITRATE_OPTIONS = [ - { value: "320k", label: "320k" }, - { value: "256k", label: "256k" }, - { value: "192k", label: "192k" }, - { value: "128k", label: "128k" }, -]; -const M4A_CODEC_OPTIONS = [ - { value: "aac", label: "AAC" }, - { value: "alac", label: "ALAC" }, -]; -const STORAGE_KEY = "spotiflac_audio_converter_state"; -export function AudioConverterPage() { - const [files, setFiles] = useState(() => { - 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 [outputFormat, setOutputFormat] = useState<"mp3" | "m4a">(() => { - try { - const saved = sessionStorage.getItem(STORAGE_KEY); - if (saved) { - const parsed = JSON.parse(saved); - if (parsed.outputFormat === "mp3" || parsed.outputFormat === "m4a") { - return parsed.outputFormat; - } - } - } - catch (err) { - } - return "mp3"; - }); - const [bitrate, setBitrate] = useState(() => { - try { - const saved = sessionStorage.getItem(STORAGE_KEY); - if (saved) { - const parsed = JSON.parse(saved); - if (parsed.bitrate) { - return parsed.bitrate; - } - } - } - catch (err) { - } - return "320k"; - }); - const [m4aCodec, setM4aCodec] = useState<"aac" | "alac">(() => { - try { - const saved = sessionStorage.getItem(STORAGE_KEY); - if (saved) { - const parsed = JSON.parse(saved); - if (parsed.m4aCodec === "aac" || parsed.m4aCodec === "alac") { - return parsed.m4aCodec; - } - } - } - catch (err) { - } - return "aac"; - }); - const [converting, setConverting] = useState(false); - const [isDragging, setIsDragging] = useState(false); - const [isFullscreen, setIsFullscreen] = useState(false); - const saveState = useCallback((stateToSave: { - files: AudioFile[]; - outputFormat: "mp3" | "m4a"; - bitrate: string; - m4aCodec: "aac" | "alac"; - }) => { - try { - sessionStorage.setItem(STORAGE_KEY, JSON.stringify(stateToSave)); - } - catch (err) { - console.error("Failed to save state:", err); - } - }, []); - useEffect(() => { - saveState({ files, outputFormat, bitrate, m4aCodec }); - }, [files, outputFormat, bitrate, m4aCodec, saveState]); - useEffect(() => { - if (files.length === 0) - return; - const allMP3 = files.every((f) => f.format === "mp3"); - if (allMP3 && outputFormat !== "m4a") { - setOutputFormat("m4a"); - } - const hasFlac = files.some((f) => f.format === "flac"); - if (!hasFlac && m4aCodec === "alac") { - setM4aCodec("aac"); - } - }, [files, outputFormat, m4aCodec]); - const isFormatDisabled = files.length > 0 && files.every((f) => f.format === "mp3"); - const hasFlacFiles = files.some((f) => f.format === "flac"); - 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 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 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 validExtensions = [".mp3", ".flac"]; - const m4aFiles = paths.filter((path) => { - const ext = path.toLowerCase().slice(path.lastIndexOf(".")); - return ext === ".m4a"; - }); - if (m4aFiles.length > 0) { - toast.error("M4A files not supported", { - description: "Only FLAC and MP3 files are supported as input. Please convert M4A files first.", - }); - } - const GetFileSizes = (files: string[]): Promise> => (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) : {}; - 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, - }; - }); - if (newFiles.length > 0) { - if (paths.length > newFiles.length) { - const skipped = paths.length - newFiles.length; - toast.info("Some files skipped", { - description: `${skipped} file(s) were skipped (unsupported format or already added)`, - }); - } - return [...prev, ...newFiles]; - } - if (paths.length > 0 && m4aFiles.length === 0) { - toast.info("No new files added", { - description: "All files were already added or have unsupported format", - }); - } - return prev; - }); - }, []); - 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 handleConvert = async () => { - if (files.length === 0) { - toast.error("No files selected", { - description: "Please add audio files to convert", - }); - return; - } - setConverting(true); - try { - const inputPaths = files.map((f) => f.path); - setFiles((prev) => prev.map((f) => { - if (inputPaths.includes(f.path)) { - return { ...f, status: "converting" as const, error: undefined }; - } - return f; - })); - const results = await ConvertAudio({ - input_files: inputPaths, - output_format: outputFormat, - bitrate: bitrate, - codec: outputFormat === "m4a" ? m4aCodec : "", - }); - setFiles((prev) => prev.map((f) => { - const result = results.find((r) => 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) => r.success).length; - const failCount = results.filter((r) => !r.success).length; - if (successCount > 0) { - toast.success("Conversion Complete", { - description: `Successfully converted ${successCount} file(s)${failCount > 0 ? `, ${failCount} failed` : ""}`, - }); - } - else if (failCount > 0) { - toast.error("Conversion Failed", { - description: `All ${failCount} file(s) failed to convert`, - }); - } - } - catch (err) { - toast.error("Conversion Error", { - description: err instanceof Error ? err.message : "Unknown error", - }); - setFiles((prev) => prev.map((f) => ({ ...f, status: "error" as const, error: "Conversion failed" }))); - } - finally { - setConverting(false); - } - }; - const getStatusIcon = (status: AudioFile["status"]) => { - switch (status) { - case "converting": - return ; - case "success": - return ; - case "error": - return ; - default: - return ; - } - }; - const convertableCount = files.filter((f) => f.status === "pending" || f.status === "success").length; - const successCount = files.filter((f) => f.status === "success").length; - return (
- -
-

Audio Converter

- {files.length > 0 && (
- - - -
)} -
- - -
{ - 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 ? (<> -
- -
-

- {isDragging - ? "Drop your audio files here" - : "Drag and drop audio files here, or click the button below to select"} -

-
- - -
-

- Supported formats: FLAC, MP3 -

- ) : (
- -
- -
-
- - { - if (value && !isFormatDisabled) - setOutputFormat(value as "mp3" | "m4a"); - }} disabled={isFormatDisabled}> - {!isFormatDisabled && ( - MP3 - )} - - M4A - - -
- - {outputFormat === "m4a" && hasFlacFiles && (
- - { - if (value) - setM4aCodec(value as "aac" | "alac"); - }}> - {M4A_CODEC_OPTIONS.map((option) => ( - {option.label} - ))} - -
)} - - {!(outputFormat === "m4a" && m4aCodec === "alac") && (
- - { - if (value) - setBitrate(value); - }}> - {BITRATE_OPTIONS.map((option) => ( - {option.label} - ))} - -
)} -
-
- - -
-
- {files.length} file(s) • {successCount} converted -
-
- - -
- {files.map((file) => (
- {getStatusIcon(file.status)} -
-

{file.name}

- {file.error && (

- {file.error} -

)} -
- - {formatFileSize(file.size)} - - - {file.format} - - {file.status !== "converting" && ()} -
))} -
- - -
- -
-
)} -
-
); -} diff --git a/frontend/src/components/AudioResamplerPage.tsx b/frontend/src/components/AudioResamplerPage.tsx deleted file mode 100644 index 55a0bc7..0000000 --- a/frontend/src/components/AudioResamplerPage.tsx +++ /dev/null @@ -1,468 +0,0 @@ -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(() => { - 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> => (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 ; - case "success": - return ; - case "error": - return ; - default: - return ; - } - }; - const resampleableCount = files.filter((f) => f.status === "pending" || f.status === "success").length; - const successCount = files.filter((f) => f.status === "success").length; - return (
- -
-

Audio Resampler

- {files.length > 0 && (
- - - -
)} -
- -
{ - 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 ? (<> -
- -
-

- {isDragging - ? "Drop your audio files here" - : "Drag and drop audio files here, or click the button below to select"} -

-
- - -
-

- Supported format: FLAC -

- ) : (
-
-
-
- - { - if (value) - setBitDepth(value); - }}> - {BIT_DEPTH_OPTIONS.map((option) => ( - {option.label} - ))} - -
- -
- - { - if (value) - setSampleRate(value); - }}> - {SAMPLE_RATE_OPTIONS.map((option) => ( - {option.label} - ))} - -
-
-
- -
-
- {files.length} file(s) • {successCount} resampled -
-
- -
- {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 (
- {getStatusIcon(file.status)} -
-

{file.name}

- {file.error && (

- {file.error} -

)} -
- - {srcSpec ? ( - {srcSpec} - ) : file.status === "pending" ? ( - reading... - ) : null} - - - {formatFileSize(file.size)} - - - {file.format} - - {file.status !== "resampling" && ()} -
); - })} -
- -
- -
-
)} -
-
); -} diff --git a/frontend/src/components/AvailabilityLinks.tsx b/frontend/src/components/AvailabilityLinks.tsx deleted file mode 100644 index 58791d9..0000000 --- a/frontend/src/components/AvailabilityLinks.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import type { ReactNode } from "react"; -import type { TrackAvailability } from "@/types/api"; -import { openExternal } from "@/lib/utils"; -import { AmazonAvailabilityIcon, QobuzAvailabilityIcon, TidalAvailabilityIcon } from "./PlatformIcons"; -interface AvailabilityLinkEntry { - id: string; - found: boolean; - url?: string; - icon: ReactNode; -} -function getAvailabilityLinkEntries(availability: TrackAvailability): AvailabilityLinkEntry[] { - const tidalUrl = availability.tidal_url?.trim() || ""; - const qobuzUrl = availability.qobuz_url?.trim() || ""; - const amazonUrl = availability.amazon_url?.trim() || ""; - return [ - { - id: "tidal", - found: tidalUrl !== "", - url: tidalUrl, - icon: , - }, - { - id: "qobuz", - found: qobuzUrl !== "", - url: qobuzUrl, - icon: , - }, - { - id: "amazon", - found: amazonUrl !== "", - url: amazonUrl, - icon: , - }, - ]; -} -export function hasAvailabilityLinks(availability?: TrackAvailability): boolean { - if (!availability) { - return false; - } - return getAvailabilityLinkEntries(availability).some((entry) => entry.found); -} -export function AvailabilityLinks({ availability }: { - availability?: TrackAvailability; -}) { - if (!availability) { - return

Check Availability

; - } - const entries = getAvailabilityLinkEntries(availability); - return (
- {entries.map((entry) => entry.found ? () : (
- {entry.icon} - - Not Found - -
))} -
); -} diff --git a/frontend/src/components/DebugLoggerPage.tsx b/frontend/src/components/DebugLoggerPage.tsx deleted file mode 100644 index ec463cd..0000000 --- a/frontend/src/components/DebugLoggerPage.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import { useState, useEffect, useRef } from "react"; -import { Trash2, Copy, Check, FileDown } from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { logger, type LogEntry } from "@/lib/logger"; -import { useDownloadQueueData } from "@/hooks/useDownloadQueueData"; -import { ExportFailedDownloads } from "../../wailsjs/go/main/App"; -import { toastWithSound as toast } from "@/lib/toast-with-sound"; -const levelColors: Record = { - info: "text-blue-500", - success: "text-green-500", - warning: "text-yellow-500", - error: "text-red-500", - debug: "text-gray-500", -}; -function formatTime(date: Date): string { - return date.toLocaleTimeString("en-US", { - hour12: false, - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - }); -} -export function DebugLoggerPage() { - const [logs, setLogs] = useState([]); - const [copied, setCopied] = useState(false); - const scrollRef = useRef(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(() => { - const unsubscribe = logger.subscribe(() => { - setLogs(logger.getLogs()); - }); - setLogs(logger.getLogs()); - return () => { - unsubscribe(); - }; - }, []); - useEffect(() => { - if (scrollRef.current) { - scrollRef.current.scrollTop = scrollRef.current.scrollHeight; - } - }, [logs]); - const handleClear = () => { - logger.clear(); - }; - const handleCopy = async () => { - const logText = logs - .map((log) => `[${formatTime(log.timestamp)}] [${log.level}] ${log.message}`) - .join("\n"); - try { - await navigator.clipboard.writeText(logText); - setCopied(true); - setTimeout(() => setCopied(false), 500); - } - catch (err) { - console.error("Failed to copy logs:", err); - } - }; - const handleExportFailed = async () => { - if (!canExportFailed) { - return; - } - try { - const message = await ExportFailedDownloads(); - if (message.startsWith("Successfully")) { - toast.success(message); - } - else if (message !== "Export cancelled") { - toast.info(message); - } - } - catch (error) { - console.error("Failed to export:", error); - toast.error(`Failed to export: ${error}`); - } - }; - return (
-
-

Debug Logs

-
- - - -
-
- -
- {logs.length === 0 ? (

no logs yet...

) : (logs.map((log, i) => (
- - [{formatTime(log.timestamp)}] - - - [{log.level}] - - {log.message} -
)))} -
-
); -} diff --git a/frontend/src/components/DownloadProgress.tsx b/frontend/src/components/DownloadProgress.tsx deleted file mode 100644 index d790e4a..0000000 --- a/frontend/src/components/DownloadProgress.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { Button } from "@/components/ui/button"; -import { Progress } from "@/components/ui/progress"; -import { StopCircle } from "lucide-react"; -interface DownloadProgressProps { - progress: number; - remainingCount?: number; - currentTrack: { - name: string; - artists: string; - } | null; - onStop: () => void; -} -export function DownloadProgress({ progress, remainingCount = 0, currentTrack, onStop }: DownloadProgressProps) { - const clampedProgress = Math.min(100, Math.max(0, progress)); - const safeRemainingCount = Math.max(0, remainingCount); - const remainingLabel = `${safeRemainingCount.toLocaleString()} ${safeRemainingCount === 1 ? "track" : "tracks"} left`; - return (
-
- - -
-

- {clampedProgress}% • {remainingLabel} -{" "} - {currentTrack - ? `${currentTrack.name} - ${currentTrack.artists}` - : "Preparing download..."} -

-
); -} diff --git a/frontend/src/components/DownloadProgressToast.tsx b/frontend/src/components/DownloadProgressToast.tsx deleted file mode 100644 index 50ee002..0000000 --- a/frontend/src/components/DownloadProgressToast.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { useDownloadProgress } from "@/hooks/useDownloadProgress"; -import { useDownloadQueueData } from "@/hooks/useDownloadQueueData"; -import { Download, ChevronRight } from "lucide-react"; -import { Button } from "@/components/ui/button"; -interface DownloadProgressToastProps { - onClick: () => void; -} -export function DownloadProgressToast({ onClick }: DownloadProgressToastProps) { - const progress = useDownloadProgress(); - const queueInfo = useDownloadQueueData(); - const hasActiveDownloads = queueInfo.queue.some(item => item.status === "queued" || item.status === "downloading"); - if (!hasActiveDownloads) { - return null; - } - return (
- -
); -} diff --git a/frontend/src/components/DownloadQueue.tsx b/frontend/src/components/DownloadQueue.tsx deleted file mode 100644 index d806aac..0000000 --- a/frontend/src/components/DownloadQueue.tsx +++ /dev/null @@ -1,274 +0,0 @@ -import { useEffect, useState } from "react"; -import { X, Download, CheckCircle2, XCircle, Clock, FileCheck, Trash2, HardDrive, Zap, Timer, FileDown } from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { Dialog, DialogContent, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { Badge } from "@/components/ui/badge"; -import { GetDownloadQueue, ClearCompletedDownloads, ClearAllDownloads, ExportFailedDownloads } from "../../wailsjs/go/main/App"; -import { toastWithSound as toast } from "@/lib/toast-with-sound"; -import { backend } from "../../wailsjs/go/models"; -interface DownloadQueueProps { - isOpen: boolean; - onClose: () => void; -} -export function DownloadQueue({ isOpen, onClose }: DownloadQueueProps) { - const [queueInfo, setQueueInfo] = useState(new backend.DownloadQueueInfo({ - is_downloading: false, - queue: [], - current_speed: 0, - total_downloaded: 0, - session_start_time: 0, - queued_count: 0, - completed_count: 0, - failed_count: 0, - skipped_count: 0, - })); - useEffect(() => { - if (!isOpen) - return; - const fetchQueue = async () => { - try { - const info = await GetDownloadQueue(); - setQueueInfo(info); - } - catch (error) { - console.error("Failed to get download queue:", error); - } - }; - fetchQueue(); - const interval = setInterval(fetchQueue, 500); - return () => clearInterval(interval); - }, [isOpen]); - const handleClearHistory = async () => { - try { - await ClearCompletedDownloads(); - const info = await GetDownloadQueue(); - setQueueInfo(info); - } - catch (error) { - console.error("Failed to clear history:", error); - } - }; - const handleReset = async () => { - try { - await ClearAllDownloads(); - const info = await GetDownloadQueue(); - setQueueInfo(info); - toast.success("Download queue reset"); - } - catch (error) { - console.error("Failed to reset queue:", error); - } - }; - const handleExportFailed = async () => { - try { - const message = await ExportFailedDownloads(); - if (message.startsWith("Successfully")) { - toast.success(message); - } - else if (message !== "Export cancelled") { - toast.info(message); - } - } - catch (error) { - console.error("Failed to export:", error); - toast.error(`Failed to export: ${error}`); - } - }; - const getStatusIcon = (status: string) => { - switch (status) { - case "downloading": - return ; - case "completed": - return ; - case "failed": - return ; - case "skipped": - return ; - case "queued": - return ; - default: - return null; - } - }; - const getStatusBadge = (status: string) => { - const variants: Record = { - downloading: "default", - completed: "outline", - failed: "destructive", - skipped: "secondary", - queued: "outline", - }; - return ( - {status} - ); - }; - const formatDuration = (startTimestamp: number) => { - if (startTimestamp === 0) - return "—"; - const now = Math.floor(Date.now() / 1000); - const durationSeconds = now - startTimestamp; - const hours = Math.floor(durationSeconds / 3600); - const minutes = Math.floor((durationSeconds % 3600) / 60); - const seconds = durationSeconds % 60; - if (hours > 0) { - return `${hours}h ${minutes}m ${seconds}s`; - } - else if (minutes > 0) { - return `${minutes}m ${seconds}s`; - } - else { - return `${seconds}s`; - } - }; - const [filterStatus, setFilterStatus] = useState("all"); - const toggleFilter = (status: string) => { - setFilterStatus(prev => prev === status ? "all" : status); - }; - const filteredQueue = queueInfo.queue.filter((item: any) => { - if (filterStatus === "all") - return true; - return item.status === filterStatus; - }); - return ( - - -
- Download Queue -
- {(queueInfo.completed_count > 0 || queueInfo.failed_count > 0 || queueInfo.skipped_count > 0) && ()} - {queueInfo.failed_count > 0 && ()} - -
-
- - -
-
toggleFilter('queued')}> - - Queued: - {queueInfo.queued_count} -
-
toggleFilter('completed')}> - - Completed: - {queueInfo.completed_count} -
-
toggleFilter('skipped')}> - - Skipped: - {queueInfo.skipped_count} -
-
toggleFilter('failed')}> - - Failed: - {queueInfo.failed_count} -
-
- - -
-
- - Downloaded: - - {queueInfo.total_downloaded > 0 ? `${queueInfo.total_downloaded.toFixed(2)} MB` : "0.00 MB"} - -
-
- - Speed: - - {queueInfo.current_speed > 0 && queueInfo.is_downloading - ? `${queueInfo.current_speed.toFixed(2)} MB/s` - : "—"} - -
-
- - Duration: - - {queueInfo.session_start_time > 0 ? formatDuration(queueInfo.session_start_time) : "—"} - -
-
- -
- - -
-
- {queueInfo.queue.length === 0 ? (
- -

No downloads in queue

-
) : filteredQueue.length === 0 ? (
-

No downloads with status "{filterStatus}"

- -
) : (filteredQueue.map((item: any) => (
-
-
{getStatusIcon(item.status)}
- -
-
-
-

{item.track_name}

-

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

-
- {getStatusBadge(item.status)} -
- - - {item.status === "downloading" && (
- - {item.progress > 0 - ? `${item.progress.toFixed(2)} MB` - : queueInfo.is_downloading && queueInfo.current_speed > 0 - ? "Downloading..." - : "Starting..."} - - - {item.speed > 0 - ? `${item.speed.toFixed(2)} MB/s` - : queueInfo.current_speed > 0 - ? `${queueInfo.current_speed.toFixed(2)} MB/s` - : "—"} - -
)} - - - {item.status === "completed" && (
- {item.progress.toFixed(2)} MB -
)} - - - {item.status === "skipped" && (
- File already exists -
)} - - - {item.status === "failed" && item.error_message && (
- {item.error_message} -
)} - - - {(item.status === "completed" || item.status === "skipped") && item.file_path && (
- {item.file_path} -
)} -
-
-
)))} -
-
-
-
); -} diff --git a/frontend/src/components/FetchHistory.tsx b/frontend/src/components/FetchHistory.tsx deleted file mode 100644 index 774b3a0..0000000 --- a/frontend/src/components/FetchHistory.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import { X, Music2, Disc3, ListMusic, UserRound } from "lucide-react"; -export interface HistoryItem { - id: string; - url: string; - type: "track" | "album" | "playlist" | "artist"; - name: string; - artist: string; - image: string; - timestamp: number; -} -interface FetchHistoryProps { - history: HistoryItem[]; - onSelect: (item: HistoryItem) => void; - onRemove: (id: string) => void; -} -export function FetchHistory({ history, onSelect, onRemove }: FetchHistoryProps) { - if (history.length === 0) - return null; - const getTypeLabel = (type: string) => { - switch (type) { - case "track": - return "Track"; - case "album": - return "Album"; - case "playlist": - return "Playlist"; - case "artist": - return "Artist"; - default: - return type; - } - }; - const getTypeIcon = (type: string) => { - switch (type) { - case "track": - return Music2; - case "album": - return Disc3; - case "playlist": - return ListMusic; - case "artist": - return UserRound; - default: - return null; - } - }; - const getTypeBadgeClass = (type: string) => { - switch (type) { - case "track": - return "bg-blue-500/10 text-blue-600 dark:bg-blue-500/20 dark:text-blue-400"; - case "album": - return "bg-green-500/10 text-green-600 dark:bg-green-500/20 dark:text-green-400"; - case "playlist": - return "bg-purple-500/10 text-purple-600 dark:bg-purple-500/20 dark:text-purple-400"; - case "artist": - return "bg-orange-500/10 text-orange-600 dark:bg-orange-500/20 dark:text-orange-400"; - default: - return "bg-muted text-muted-foreground"; - } - }; - return (
- {history.length === 1 ? "Recent Fetch" : "Recent Fetches"} -
- {history.map((item) => (
onSelect(item)}> - -
-
- {item.image ? ({item.name}) : (
- No Image -
)} -
-
-

- {item.name} -

-

- {item.artist} -

- {(() => { - const IconComponent = getTypeIcon(item.type); - return ( - {IconComponent ? : null} - {getTypeLabel(item.type)} - ); - })()} -
-
-
))} -
-
); -} diff --git a/frontend/src/components/FileManagerPage.tsx b/frontend/src/components/FileManagerPage.tsx deleted file mode 100644 index 5deba40..0000000 --- a/frontend/src/components/FileManagerPage.tsx +++ /dev/null @@ -1,743 +0,0 @@ -import { useState, useEffect, useCallback } from "react"; -import { Button } from "@/components/ui/button"; -import { Label } from "@/components/ui/label"; -import { InputWithContext } from "@/components/ui/input-with-context"; -import { Checkbox } from "@/components/ui/checkbox"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { FolderOpen, RefreshCw, FileMusic, ChevronRight, ChevronDown, Pencil, Eye, Folder, Info, RotateCcw, FileText, Image, Copy, Check, } from "lucide-react"; -import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; -import { Spinner } from "@/components/ui/spinner"; -import { Badge } from "@/components/ui/badge"; -import { SelectFolder } from "../../wailsjs/go/main/App"; -import { backend } from "../../wailsjs/go/models"; -import { toastWithSound as toast } from "@/lib/toast-with-sound"; -import { getSettings } from "@/lib/settings"; -import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -const ListDirectoryFiles = (path: string): Promise => (window as any)['go']['main']['App']['ListDirectoryFiles'](path); -const PreviewRenameFiles = (files: string[], format: string): Promise => (window as any)['go']['main']['App']['PreviewRenameFiles'](files, format); -const RenameFilesByMetadata = (files: string[], format: string): Promise => (window as any)['go']['main']['App']['RenameFilesByMetadata'](files, format); -const ReadFileMetadata = (path: string): Promise => (window as any)['go']['main']['App']['ReadFileMetadata'](path); -const ReadTextFile = (path: string): Promise => (window as any)['go']['main']['App']['ReadTextFile'](path); -const RenameFileTo = (oldPath: string, newName: string): Promise => (window as any)['go']['main']['App']['RenameFileTo'](oldPath, newName); -const ReadImageAsBase64 = (path: string): Promise => (window as any)['go']['main']['App']['ReadImageAsBase64'](path); -interface FileNode { - name: string; - path: string; - is_dir: boolean; - size: number; - children?: FileNode[]; - expanded?: boolean; -} -interface FileMetadata { - title: string; - artist: string; - album: string; - album_artist: string; - track_number: number; - disc_number: number; - year: string; - upc?: string; - isrc?: string; -} -type TabType = "track" | "lyric" | "cover"; -const FORMAT_PRESETS: Record = { - "title": { label: "Title", template: "{title}" }, - "title-artist": { label: "Title - Artist", template: "{title} - {artist}" }, - "artist-title": { label: "Artist - Title", template: "{artist} - {title}" }, - "track-title": { label: "Track. Title", template: "{track}. {title}" }, - "track-title-artist": { label: "Track. Title - Artist", template: "{track}. {title} - {artist}" }, - "track-artist-title": { label: "Track. Artist - Title", template: "{track}. {artist} - {title}" }, - "title-album-artist": { label: "Title - Album Artist", template: "{title} - {album_artist}" }, - "track-title-album-artist": { label: "Track. Title - Album Artist", template: "{track}. {title} - {album_artist}" }, - "artist-album-title": { label: "Artist - Album - Title", template: "{artist} - {album} - {title}" }, - "track-dash-title": { label: "Track - Title", template: "{track} - {title}" }, - "disc-track-title": { label: "Disc-Track. Title", template: "{disc}-{track}. {title}" }, - "disc-track-title-artist": { label: "Disc-Track. Title - Artist", template: "{disc}-{track}. {title} - {artist}" }, - "custom": { label: "Custom...", template: "{title} - {artist}" }, -}; -const STORAGE_KEY = "spotiflac_file_manager_state"; -const DEFAULT_PRESET = "title-artist"; -const DEFAULT_CUSTOM_FORMAT = "{title} - {artist}"; -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]; -} -export function FileManagerPage() { - const [rootPath, setRootPath] = useState(() => { - const settings = getSettings(); - return settings.downloadPath || ""; - }); - const [allFiles, setAllFiles] = useState([]); - const [selectedFiles, setSelectedFiles] = useState>(new Set()); - const [loading, setLoading] = useState(false); - const [activeTab, setActiveTab] = useState("track"); - const [formatPreset, setFormatPreset] = useState(() => { - try { - const saved = localStorage.getItem(STORAGE_KEY); - if (saved) { - const parsed = JSON.parse(saved); - if (parsed.formatPreset && FORMAT_PRESETS[parsed.formatPreset]) { - return parsed.formatPreset; - } - } - } - catch { } - return DEFAULT_PRESET; - }); - const [customFormat, setCustomFormat] = useState(() => { - try { - const saved = localStorage.getItem(STORAGE_KEY); - if (saved) { - const parsed = JSON.parse(saved); - if (parsed.customFormat) - return parsed.customFormat; - } - } - catch { } - return DEFAULT_CUSTOM_FORMAT; - }); - const renameFormat = formatPreset === "custom" ? (customFormat || FORMAT_PRESETS["custom"].template) : FORMAT_PRESETS[formatPreset].template; - const [showPreview, setShowPreview] = useState(false); - const [previewData, setPreviewData] = useState([]); - const [renaming, setRenaming] = useState(false); - const [previewOnly, setPreviewOnly] = useState(false); - const [isFullscreen, setIsFullscreen] = useState(false); - const [showResetConfirm, setShowResetConfirm] = useState(false); - const [showMetadata, setShowMetadata] = useState(false); - const [metadataFile, setMetadataFile] = useState(""); - const [metadataInfo, setMetadataInfo] = useState(null); - const [loadingMetadata, setLoadingMetadata] = useState(false); - const [showLyricsPreview, setShowLyricsPreview] = useState(false); - const [lyricsContent, setLyricsContent] = useState(""); - const [lyricsFile, setLyricsFile] = useState(""); - const [lyricsTab, setLyricsTab] = useState<"synced" | "plain">("synced"); - const [copySuccess, setCopySuccess] = useState(false); - const [showCoverPreview, setShowCoverPreview] = useState(false); - const [coverFile, setCoverFile] = useState(""); - const [coverData, setCoverData] = useState(""); - const [showManualRename, setShowManualRename] = useState(false); - const [manualRenameFile, setManualRenameFile] = useState(""); - const [manualRenameName, setManualRenameName] = useState(""); - const [manualRenaming, setManualRenaming] = useState(false); - useEffect(() => { - try { - localStorage.setItem(STORAGE_KEY, JSON.stringify({ formatPreset, customFormat })); - } - catch { } - }, [formatPreset, customFormat]); - 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 filterFilesByType = (nodes: FileNode[], type: TabType): FileNode[] => { - return nodes - .map((node) => { - if (node.is_dir && node.children) { - const filteredChildren = filterFilesByType(node.children, type); - if (filteredChildren.length > 0) { - return { ...node, children: filteredChildren }; - } - return null; - } - const ext = node.name.toLowerCase(); - if (type === "track" && (ext.endsWith(".flac") || ext.endsWith(".mp3") || ext.endsWith(".m4a"))) - return node; - if (type === "lyric" && ext.endsWith(".lrc")) - return node; - if (type === "cover" && (ext.endsWith(".jpg") || ext.endsWith(".jpeg") || ext.endsWith(".png"))) - return node; - return null; - }) - .filter((node): node is FileNode => node !== null); - }; - const loadFiles = useCallback(async () => { - if (!rootPath) - return; - setLoading(true); - try { - const result = await ListDirectoryFiles(rootPath); - if (!result || !Array.isArray(result)) { - setAllFiles([]); - setSelectedFiles(new Set()); - return; - } - setAllFiles(result as FileNode[]); - setSelectedFiles(new Set()); - } - catch (err) { - const errorMsg = err instanceof Error ? err.message : String(err || ""); - if (!errorMsg.toLowerCase().includes("empty") && !errorMsg.toLowerCase().includes("no file")) { - toast.error("Failed to load files", { description: errorMsg || "Unknown error" }); - } - setAllFiles([]); - setSelectedFiles(new Set()); - } - finally { - setLoading(false); - } - }, [rootPath]); - useEffect(() => { - if (rootPath) - loadFiles(); - }, [rootPath, loadFiles]); - const filteredFiles = filterFilesByType(allFiles, activeTab); - const getAllFilesFlat = (nodes: FileNode[]): FileNode[] => { - const result: FileNode[] = []; - for (const node of nodes) { - if (!node.is_dir) - result.push(node); - if (node.children) - result.push(...getAllFilesFlat(node.children)); - } - return result; - }; - const allAudioFiles = getAllFilesFlat(filterFilesByType(allFiles, "track")); - const allLyricFiles = getAllFilesFlat(filterFilesByType(allFiles, "lyric")); - const allCoverFiles = getAllFilesFlat(filterFilesByType(allFiles, "cover")); - const handleSelectFolder = async () => { - try { - const path = await SelectFolder(rootPath); - if (path) - setRootPath(path); - } - catch (err) { - toast.error("Failed to select folder", { description: err instanceof Error ? err.message : "Unknown error" }); - } - }; - const toggleExpand = (path: string) => { - setAllFiles((prev) => toggleNodeExpand(prev, path)); - }; - const toggleNodeExpand = (nodes: FileNode[], path: string): FileNode[] => { - return nodes.map((node) => { - if (node.path === path) - return { ...node, expanded: !node.expanded }; - if (node.children) - return { ...node, children: toggleNodeExpand(node.children, path) }; - return node; - }); - }; - const toggleSelect = (path: string) => { - setSelectedFiles((prev) => { - const newSet = new Set(prev); - if (newSet.has(path)) - newSet.delete(path); - else - newSet.add(path); - return newSet; - }); - }; - const toggleFolderSelect = (node: FileNode) => { - const folderFiles = getAllFilesFlat([node]); - const allSelected = folderFiles.every((f) => selectedFiles.has(f.path)); - setSelectedFiles((prev) => { - const newSet = new Set(prev); - if (allSelected) - folderFiles.forEach((f) => newSet.delete(f.path)); - else - folderFiles.forEach((f) => newSet.add(f.path)); - return newSet; - }); - }; - const isFolderSelected = (node: FileNode): boolean | "indeterminate" => { - const folderFiles = getAllFilesFlat([node]); - if (folderFiles.length === 0) - return false; - const selectedCount = folderFiles.filter((f) => selectedFiles.has(f.path)).length; - if (selectedCount === 0) - return false; - if (selectedCount === folderFiles.length) - return true; - return "indeterminate"; - }; - const selectAll = () => setSelectedFiles(new Set(allAudioFiles.map((f) => f.path))); - const deselectAll = () => setSelectedFiles(new Set()); - const resetToDefault = () => { setFormatPreset(DEFAULT_PRESET); setCustomFormat(DEFAULT_CUSTOM_FORMAT); setShowResetConfirm(false); }; - const handlePreview = async (isPreviewOnly: boolean) => { - if (selectedFiles.size === 0) { - toast.error("No files selected"); - return; - } - try { - const result = await PreviewRenameFiles(Array.from(selectedFiles), renameFormat); - setPreviewData(result); - setPreviewOnly(isPreviewOnly); - setShowPreview(true); - } - catch (err) { - toast.error("Failed to generate preview", { description: err instanceof Error ? err.message : "Unknown error" }); - } - }; - const handleShowMetadata = async (filePath: string, e: React.MouseEvent) => { - e.stopPropagation(); - setMetadataFile(filePath); - setLoadingMetadata(true); - try { - const metadata = await ReadFileMetadata(filePath); - setMetadataInfo(metadata as FileMetadata); - setShowMetadata(true); - } - catch (err) { - toast.error("Failed to read metadata", { description: err instanceof Error ? err.message : "Unknown error" }); - setMetadataInfo(null); - } - finally { - setLoadingMetadata(false); - } - }; - const handleShowLyrics = async (filePath: string, e: React.MouseEvent) => { - e.stopPropagation(); - setLyricsFile(filePath); - setLyricsTab("synced"); - try { - const content = await ReadTextFile(filePath); - setLyricsContent(content); - setShowLyricsPreview(true); - } - catch (err) { - toast.error("Failed to read lyrics file", { description: err instanceof Error ? err.message : "Unknown error" }); - } - }; - const handleShowCover = async (filePath: string, e: React.MouseEvent) => { - e.stopPropagation(); - setCoverFile(filePath); - try { - const data = await ReadImageAsBase64(filePath); - setCoverData(data); - setShowCoverPreview(true); - } - catch (err) { - toast.error("Failed to load image", { description: err instanceof Error ? err.message : "Unknown error" }); - } - }; - const getPlainLyrics = (content: string) => { - return content.split('\n').map(line => line.replace(/^\[[\d:.]+\]\s*/, '')).filter(line => !line.startsWith('[') || line.includes(']')).map(line => line.startsWith('[') ? '' : line).join('\n').trim(); - }; - const formatTimestamp = (timestamp: string): string => { - const match = timestamp.match(/\[(\d+):(\d+)(?:\.(\d+))?\]/); - if (!match) - return timestamp; - const minutes = parseInt(match[1], 10); - const seconds = match[2]; - return `${minutes}:${seconds}`; - }; - const renderSyncedLyrics = (content: string) => { - if (!content) - return
No lyrics content
; - const lines = content.split('\n'); - return lines.map((line, index) => { - if (line.match(/^\[(ti|ar|al|by|length|offset):/i)) - return null; - const match = line.match(/^(\[[\d:.]+\])(.*)$/); - if (match) { - const timestamp = match[1]; - const text = match[2].trim(); - if (!text) - return null; - return (
- - {formatTimestamp(timestamp)} - - {text} -
); - } - if (!line.trim()) - return null; - return (
- {line} -
); - }).filter(item => item !== null); - }; - const handleCopyLyrics = async () => { - try { - const textToCopy = lyricsTab === "synced" ? lyricsContent : getPlainLyrics(lyricsContent); - await navigator.clipboard.writeText(textToCopy); - setCopySuccess(true); - setTimeout(() => setCopySuccess(false), 500); - } - catch { - toast.error("Failed to copy lyrics"); - } - }; - const handleManualRename = (filePath: string, e: React.MouseEvent) => { - e.stopPropagation(); - const fileName = filePath.split(/[/\\]/).pop() || ""; - const nameWithoutExt = fileName.replace(/\.[^.]+$/, ""); - setManualRenameFile(filePath); - setManualRenameName(nameWithoutExt); - setShowManualRename(true); - }; - const handleConfirmManualRename = async () => { - if (!manualRenameFile || !manualRenameName.trim()) - return; - setManualRenaming(true); - try { - await RenameFileTo(manualRenameFile, manualRenameName.trim()); - toast.success("File renamed successfully"); - setShowManualRename(false); - loadFiles(); - } - catch (err) { - toast.error("Failed to rename file", { description: err instanceof Error ? err.message : "Unknown error" }); - } - finally { - setManualRenaming(false); - } - }; - const handleRename = async () => { - if (selectedFiles.size === 0) - return; - setRenaming(true); - try { - const result = await RenameFilesByMetadata(Array.from(selectedFiles), renameFormat); - const successCount = result.filter((r: backend.RenameResult) => r.success).length; - const failCount = result.filter((r: backend.RenameResult) => !r.success).length; - if (successCount > 0) - toast.success("Rename Complete", { description: `${successCount} file(s) renamed${failCount > 0 ? `, ${failCount} failed` : ""}` }); - else - toast.error("Rename Failed", { description: `All ${failCount} file(s) failed to rename` }); - setShowPreview(false); - setSelectedFiles(new Set()); - loadFiles(); - } - catch (err) { - toast.error("Rename Failed", { description: err instanceof Error ? err.message : "Unknown error" }); - } - finally { - setRenaming(false); - } - }; - const renderTrackTree = (nodes: FileNode[], depth = 0) => { - return nodes.map((node) => (
-
(node.is_dir ? toggleExpand(node.path) : toggleSelect(node.path))}> - {node.is_dir ? (<> - { - if (el) - (el as HTMLButtonElement).dataset.state = isFolderSelected(node) === "indeterminate" ? "indeterminate" : isFolderSelected(node) ? "checked" : "unchecked"; - }} onCheckedChange={() => toggleFolderSelect(node)} onClick={(e) => e.stopPropagation()} className="shrink-0 data-[state=indeterminate]:bg-primary data-[state=indeterminate]:text-primary-foreground"/> - {node.expanded ? : } - - ) : (<> - toggleSelect(node.path)} onClick={(e) => e.stopPropagation()} className="shrink-0"/> - - )} - - {node.name} - {node.is_dir && ({getAllFilesFlat([node]).length})} - - {!node.is_dir && (<> - {formatFileSize(node.size)} - - - - - View Metadata - - )} -
- {node.is_dir && node.expanded && node.children &&
{renderTrackTree(node.children, depth + 1)}
} -
)); - }; - const renderLyricTree = (nodes: FileNode[], depth = 0) => { - return nodes.map((node) => (
-
node.is_dir ? toggleExpand(node.path) : handleShowLyrics(node.path, e)}> - {node.is_dir ? (<> - {node.expanded ? : } - - ) : ()} - - {node.name} - {node.is_dir && ({getAllFilesFlat([node]).length})} - - {!node.is_dir && (<> - {formatFileSize(node.size)} - - - - - Rename - - )} -
- {node.is_dir && node.expanded && node.children &&
{renderLyricTree(node.children, depth + 1)}
} -
)); - }; - const renderCoverTree = (nodes: FileNode[], depth = 0) => { - return nodes.map((node) => (
-
node.is_dir ? toggleExpand(node.path) : handleShowCover(node.path, e)}> - {node.is_dir ? (<> - {node.expanded ? : } - - ) : ()} - - {node.name} - {node.is_dir && ({getAllFilesFlat([node]).length})} - - {!node.is_dir && (<> - {formatFileSize(node.size)} - - - - - Rename - - )} -
- {node.is_dir && node.expanded && node.children &&
{renderCoverTree(node.children, depth + 1)}
} -
)); - }; - const allSelected = allAudioFiles.length > 0 && selectedFiles.size === allAudioFiles.length; - return (
-
-

File Manager

-
- - -
- setRootPath(e.target.value)} placeholder="Select a folder..." className="flex-1"/> - - -
- - -
- - - -
- - - {activeTab === "track" && (
-
- - - - - - -

Variables: {"{title}"}, {"{artist}"}, {"{album}"}, {"{album_artist}"}, {"{track}"}, {"{disc}"}, {"{year}"}, {"{date}"}, {"{isrc}"}

-
-
-
-
- - {formatPreset === "custom" && ( setCustomFormat(e.target.value)} placeholder="{artist} - {title}" className="flex-1"/>)} - - - - - Reset to Default - -
-

- Preview: {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").replace(/\{isrc\}/g, "USUM71801234")}.flac -

-
)} - - -
- {activeTab === "track" && (
-
- - {selectedFiles.size} of {allAudioFiles.length} file(s) selected -
-
- - -
-
)} - -
- {loading ? (
) : filteredFiles.length === 0 ? (
- {rootPath ? `No ${activeTab} files found` : "Select a folder to browse"} -
) : (activeTab === "track" ? renderTrackTree(filteredFiles) : - activeTab === "lyric" ? renderLyricTree(filteredFiles) : - renderCoverTree(filteredFiles))} -
-
- - - - - - Reset to Default? - This will reset the rename format to "Title - Artist". Your custom format will be lost. - - - - - - - - - - - - - Rename Preview - Review the changes before renaming. Files with errors will be skipped. - -
- {previewData.map((item, index) => (
-
-
{item.old_name}
- {item.error ?
{item.error}
:
→ {item.new_name}
} -
-
))} -
- - {previewOnly ? () : (<> - - - )} - -
-
- - - - - - File Metadata - {metadataFile.split(/[/\\]/).pop()} - - {loadingMetadata ? (
) : metadataInfo ? (
-
Title{metadataInfo.title || "-"}
-
Artist{metadataInfo.artist || "-"}
-
Album{metadataInfo.album || "-"}
-
Album Artist{metadataInfo.album_artist || "-"}
-
Track{metadataInfo.track_number || "-"}
-
Disc{metadataInfo.disc_number || "-"}
-
Year{metadataInfo.year ? metadataInfo.year.substring(0, 4) : "-"}
-
UPC{metadataInfo.upc || "-"}
-
ISRC{metadataInfo.isrc || "-"}
-
) : (
No metadata available
)} - -
-
- - - - - - - - - Lyrics Preview - {lyricsFile.split(/[/\\]/).pop()} - -
- - -
-
- {lyricsTab === "synced" ? (
- {renderSyncedLyrics(lyricsContent)} -
) : (
-            {getPlainLyrics(lyricsContent) || "No lyrics content"}
-          
)} -
- - - - -
-
- - - - - - Cover Preview - {coverFile.split(/[/\\]/).pop()} - -
- {coverData ? Cover :
Loading...
} -
- -
-
- - - - - - Rename File - {manualRenameFile.split(/[/\\]/).pop()} - -
- -
- setManualRenameName(e.target.value)} placeholder="Enter new name" className="flex-1" onKeyDown={(e) => { - if (e.key === "Enter" && !manualRenaming) - handleConfirmManualRename(); - }}/> - {manualRenameFile.match(/\.[^.]+$/)?.[0] || ""} -
-
- - - - -
-
-
); -} diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx deleted file mode 100644 index 9004e26..0000000 --- a/frontend/src/components/Header.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { Badge } from "@/components/ui/badge"; -import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; -import { openExternal } from "@/lib/utils"; -import { formatRelativeTime } from "@/lib/relative-time"; -interface HeaderProps { - version: string; - hasUpdate: boolean; - releaseDate?: string | null; -} -export function Header({ version, hasUpdate, releaseDate }: HeaderProps) { - return (
-
-
- SpotiFLAC window.location.reload()}/> -

window.location.reload()}> - SpotiFLAC -

-
- - - - - - - {hasUpdate && releaseDate && ( -

{formatRelativeTime(releaseDate)}

-
)} -
- {hasUpdate && ( - - - )} -
-
-

- Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account required. -

-
-
); -} diff --git a/frontend/src/components/HistoryPage.tsx b/frontend/src/components/HistoryPage.tsx deleted file mode 100644 index 499cd4d..0000000 --- a/frontend/src/components/HistoryPage.tsx +++ /dev/null @@ -1,706 +0,0 @@ -import { useEffect, useState, useRef } from "react"; -import { Button } from "@/components/ui/button"; -import { Trash2, ExternalLink, Search, ArrowUpDown, History, Play, Pause, Database, CloudUpload, Music2, Disc3, ListMusic, UserRound } from "lucide-react"; -import { Badge } from "@/components/ui/badge"; -import { Input } from "@/components/ui/input"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; -import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious } from "@/components/ui/pagination"; -import { GetDownloadHistory, ClearDownloadHistory, GetPreviewURL, GetFetchHistory, DeleteDownloadHistoryItem, DeleteFetchHistoryItem, ClearFetchHistoryByType } from "../../wailsjs/go/main/App"; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; -import { openExternal } from "@/lib/utils"; -import { getPreviewVolume } from "@/lib/preview"; -import { createPreviewPlayback, type PreviewPlayback } from "@/lib/preview-player"; -import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons"; -const formatDate = (timestamp: number) => { - const date = new Date(timestamp * 1000); - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, '0'); - const day = String(date.getDate()).padStart(2, '0'); - const hours = String(date.getHours()).padStart(2, '0'); - const minutes = String(date.getMinutes()).padStart(2, '0'); - const seconds = String(date.getSeconds()).padStart(2, '0'); - return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; -}; -const getHistoryFormatLabel = (item: DownloadHistoryItem) => { - const normalizedPath = (item.path || "").trim().toLowerCase(); - if (normalizedPath.endsWith(".flac")) - return "FLAC"; - if (normalizedPath.endsWith(".mp3")) - return "MP3"; - if (normalizedPath.endsWith(".m4a")) - return "M4A"; - const normalizedFormat = (item.format || "").trim().toLowerCase(); - switch (normalizedFormat) { - case "hi_res": - case "hi_res_lossless": - case "lossless": - case "flac": - case "6": - case "7": - case "27": - return "FLAC"; - case "alac": - case "apple": - case "atmos": - case "m4a": - case "m4a-aac": - case "m4a-alac": - return "M4A"; - case "mp3": - return "MP3"; - default: - return (item.format || "-").toUpperCase(); - } -}; -interface DownloadHistoryItem { - id: string; - spotify_id: string; - title: string; - artists: string; - album: string; - duration_str: string; - cover_url: string; - quality: string; - format: string; - path: string; - source: string; - timestamp: number; -} -interface FetchHistoryItem { - id: string; - url: string; - type: string; - name: string; - info: string; - image: string; - data: string; - timestamp: number; -} -interface HistoryPageProps { - onHistorySelect?: (cachedData: string) => void; -} -export function HistoryPage({ onHistorySelect }: HistoryPageProps) { - const [activeTab, setActiveTab] = useState("downloads"); - const [downloadHistory, setDownloadHistory] = useState([]); - const [filteredDownloadHistory, setFilteredDownloadHistory] = useState([]); - const [showClearDownloadConfirm, setShowClearDownloadConfirm] = useState(false); - const [downloadSearchQuery, setDownloadSearchQuery] = useState(""); - const [downloadSortBy, setDownloadSortBy] = useState("default"); - const [downloadCurrentPage, setDownloadCurrentPage] = useState(1); - const [playingPreviewId, setPlayingPreviewId] = useState(null); - const playbackRef = useRef(null); - const [fetchHistory, setFetchHistory] = useState([]); - const [filteredFetchHistory, setFilteredFetchHistory] = useState([]); - const [activeFetchTab, setActiveFetchTab] = useState("track"); - const [showClearFetchConfirm, setShowClearFetchConfirm] = useState(false); - const [fetchSearchQuery, setFetchSearchQuery] = useState(""); - const [fetchCurrentPage, setFetchCurrentPage] = useState(1); - 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 ; - if (s.includes("qobuz")) - return ; - if (s.includes("amazon")) - return ; - if (s.includes("deezer")) - return ; - if (s.includes("spotify")) - return ; - return ; - }; - const fetchDownloadHistory = async () => { - try { - const items = await GetDownloadHistory(); - setDownloadHistory((items || []) as unknown as DownloadHistoryItem[]); - } - catch (err) { - console.error("Failed to fetch download history:", err); - } - }; - const fetchFetchHistory = async () => { - try { - const items = await GetFetchHistory(); - setFetchHistory(items || []); - } - catch (err) { - console.error("Failed to fetch fetch history:", err); - } - }; - useEffect(() => { - if (activeTab === "downloads") { - fetchDownloadHistory(); - const interval = setInterval(fetchDownloadHistory, 5000); - return () => clearInterval(interval); - } - else { - fetchFetchHistory(); - const interval = setInterval(fetchFetchHistory, 5000); - return () => clearInterval(interval); - } - }, [activeTab]); - useEffect(() => { - return () => { - playbackRef.current?.destroy(); - playbackRef.current = null; - }; - }, []); - useEffect(() => { - let result = [...downloadHistory]; - if (downloadSearchQuery) { - const query = downloadSearchQuery.toLowerCase(); - result = result.filter(item => item.title.toLowerCase().includes(query) || - item.artists.toLowerCase().includes(query) || - item.album.toLowerCase().includes(query)); - } - const parseDuration = (str: string) => { - const parts = str.split(':').map(Number); - if (parts.length === 2) - return parts[0] * 60 + parts[1]; - if (parts.length === 3) - return parts[0] * 3600 + parts[1] * 60 + parts[2]; - return 0; - }; - result.sort((a, b) => { - switch (downloadSortBy) { - case "default": - case "date_desc": return b.timestamp - a.timestamp; - case "date_asc": return a.timestamp - b.timestamp; - case "title_asc": return a.title.localeCompare(b.title); - case "title_desc": return b.title.localeCompare(a.title); - case "artist_asc": return a.artists.localeCompare(b.artists); - case "artist_desc": return b.artists.localeCompare(a.artists); - case "duration_asc": return parseDuration(a.duration_str) - parseDuration(b.duration_str); - case "duration_desc": return parseDuration(b.duration_str) - parseDuration(a.duration_str); - default: return 0; - } - }); - setFilteredDownloadHistory(result); - }, [downloadHistory, downloadSearchQuery, downloadSortBy]); - useEffect(() => { - setDownloadCurrentPage(1); - }, [downloadSearchQuery, downloadSortBy]); - useEffect(() => { - let result = [...fetchHistory]; - if (activeFetchTab !== "all") { - result = result.filter(item => item.type.toLowerCase() === activeFetchTab.toLowerCase()); - } - if (fetchSearchQuery) { - const query = fetchSearchQuery.toLowerCase(); - result = result.filter(item => item.name.toLowerCase().includes(query) || - item.info.toLowerCase().includes(query)); - } - result.sort((a, b) => b.timestamp - a.timestamp); - setFilteredFetchHistory(result); - }, [fetchHistory, fetchSearchQuery, activeFetchTab]); - useEffect(() => { - setFetchCurrentPage(1); - }, [fetchSearchQuery, activeFetchTab]); - const handlePreview = async (id: string, spotifyId: string) => { - if (playingPreviewId === id) { - playbackRef.current?.destroy(); - playbackRef.current = null; - setPlayingPreviewId(null); - return; - } - if (playbackRef.current) { - playbackRef.current.destroy(); - playbackRef.current = null; - } - try { - const url = await GetPreviewURL(spotifyId); - if (url) { - const playback = await createPreviewPlayback(url, getPreviewVolume()); - const audio = playback.audio; - playbackRef.current = playback; - audio.onended = () => { - setPlayingPreviewId(null); - if (playbackRef.current?.audio === audio) { - playbackRef.current.destroy(); - playbackRef.current = null; - } - }; - audio.onerror = () => { - setPlayingPreviewId(null); - if (playbackRef.current?.audio === audio) { - playbackRef.current.destroy(); - playbackRef.current = null; - } - }; - audio.play(); - setPlayingPreviewId(id); - } - } - catch (e) { - console.error("Failed to play preview:", e); - } - }; - const handleClearDownloadHistory = async () => { - await ClearDownloadHistory(); - fetchDownloadHistory(); - setShowClearDownloadConfirm(false); - }; - const handleDeleteDownloadItem = async (id: string) => { - await DeleteDownloadHistoryItem(id); - setDownloadHistory(prev => prev.filter(item => item.id !== id)); - }; - const handleClearFetchHistory = async () => { - await ClearFetchHistoryByType(activeFetchTab); - fetchFetchHistory(); - setShowClearFetchConfirm(false); - }; - const handleDeleteFetchItem = async (id: string, e: React.MouseEvent) => { - e.stopPropagation(); - await DeleteFetchHistoryItem(id); - setFetchHistory(prev => prev.filter(item => item.id !== id)); - }; - const getPaginationPages = (current: number, total: number): (number | 'ellipsis')[] => { - if (total <= 10) - return Array.from({ length: total }, (_, i) => i + 1); - const pages: (number | 'ellipsis')[] = []; - pages.push(1); - if (current <= 7) { - for (let i = 2; i <= 10; i++) - pages.push(i); - pages.push('ellipsis'); - pages.push(total); - } - else if (current >= total - 7) { - pages.push('ellipsis'); - for (let i = total - 9; i <= total; i++) - pages.push(i); - } - else { - pages.push('ellipsis'); - pages.push(current - 1); - pages.push(current); - pages.push(current + 1); - pages.push('ellipsis'); - pages.push(total); - } - return pages; - }; - const renderDownloadHistory = () => { - const totalPages = Math.ceil(filteredDownloadHistory.length / ITEMS_PER_PAGE); - const startIndex = (downloadCurrentPage - 1) * ITEMS_PER_PAGE; - const paginated = filteredDownloadHistory.slice(startIndex, startIndex + ITEMS_PER_PAGE); - return (
-
-
-
-

Downloads

- {filteredDownloadHistory.length > 0 && ( - {filteredDownloadHistory.length.toLocaleString('en-US')} - )} -
- -
- -
-
- - setDownloadSearchQuery(e.target.value)} className="pl-8 h-9"/> -
- -
-
- -
- {paginated.length === 0 ? (
-
- -
-
-

No download history

-

Your downloaded tracks will appear here.

-
-
) : ( - - - - - - - - - - - - - - {paginated.map((item, index) => ( - - - - - - - - - ))} - -
#TitleAlbumFormatDurDownloaded AtSourceActions
- {startIndex + index + 1} - -
- {item.album} { (e.target as HTMLImageElement).src = "https://placehold.co/300?text=No+Cover"; }}/> -
- {item.title} - {item.artists} -
-
-
-
{item.album}
-
-
- - {getHistoryFormatLabel(item)} - - {item.quality && {item.quality}} -
-
- {item.duration_str} - -
- {formatDate(item.timestamp).split(' ')[0]} - {formatDate(item.timestamp).split(' ')[1]} -
-
-
- - - -
- {getSourceIcon(item.source)} -
-
- -

{item.source || "Unknown"}

-
-
-
-
-
-
- {!(item.spotify_id?.startsWith('tidal_') || item.spotify_id?.startsWith('qobuz_') || item.spotify_id?.startsWith('amazon_') || item.spotify_id?.startsWith('deezer_')) && ( - - - - - -

{playingPreviewId === item.id ? "Pause Preview" : "Play Preview"}

-
-
-
)} - - - - - - - -

{getTrackLink(item.spotify_id).label}

-
-
-
- - - - - - - -

Delete

-
-
-
-
-
)} -
- - {totalPages > 1 && ( - - - { - e.preventDefault(); - if (downloadCurrentPage > 1) - setDownloadCurrentPage(downloadCurrentPage - 1); - }} className={downloadCurrentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"}/> - - - {getPaginationPages(downloadCurrentPage, totalPages).map((page, index) => (page === 'ellipsis' ? ( - - ) : ( - { - e.preventDefault(); - setDownloadCurrentPage(page as number); - }} isActive={downloadCurrentPage === page} className="cursor-pointer"> - {page} - - )))} - - - { - e.preventDefault(); - if (downloadCurrentPage < totalPages) - setDownloadCurrentPage(downloadCurrentPage + 1); - }} className={downloadCurrentPage === totalPages ? "pointer-events-none opacity-50" : "cursor-pointer"}/> - - - )} -
); - }; - const renderFetchHistory = () => { - const totalPages = Math.ceil(filteredFetchHistory.length / ITEMS_PER_PAGE); - const startIndex = (fetchCurrentPage - 1) * ITEMS_PER_PAGE; - const paginated = filteredFetchHistory.slice(startIndex, startIndex + ITEMS_PER_PAGE); - return (
-
-
-
-

Fetches

- {fetchHistory.length > 0 && ( - {fetchHistory.length.toLocaleString('en-US')} - )} -
- -
- - -
-
- - - - -
- -
-
- - setFetchSearchQuery(e.target.value)} className="pl-8 h-9"/> -
-
-
-
- -
- {paginated.length === 0 ? (
- -
-

No fetch history

-

Fetched metadata will appear here.

-
-
) : ( - - - - - - - - - - - {paginated.map((item, index) => ( - - - - - - ))} - -
# - {activeFetchTab === 'artist' ? 'Name' : 'Title'} - DetailsFetched AtActions
- {startIndex + index + 1} - -
-
- {item.image ? ({item.name}) : (
- {item.type.slice(0, 2).toUpperCase()} -
)} -
- {item.name} -
-
-
{item.info}
-
-
- {formatDate(item.timestamp).split(' ')[0]} - {formatDate(item.timestamp).split(' ')[1]} -
-
-
- - - - - - -

Load

-
-
-
- - - - - - - -

Delete

-
-
-
-
-
)} -
- - {totalPages > 1 && ( - - - { - e.preventDefault(); - if (fetchCurrentPage > 1) - setFetchCurrentPage(fetchCurrentPage - 1); - }} className={fetchCurrentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"}/> - - - {getPaginationPages(fetchCurrentPage, totalPages).map((page, index) => (page === 'ellipsis' ? ( - - ) : ( - { - e.preventDefault(); - setFetchCurrentPage(page as number); - }} isActive={fetchCurrentPage === page} className="cursor-pointer"> - {page} - - )))} - - - { - e.preventDefault(); - if (fetchCurrentPage < totalPages) - setFetchCurrentPage(fetchCurrentPage + 1); - }} className={fetchCurrentPage === totalPages ? "pointer-events-none opacity-50" : "cursor-pointer"}/> - - - )} -
); - }; - return (
-
-

History

-
- -
-
- - -
-
- - {activeTab === "downloads" && (
- {renderDownloadHistory()} -
)} - - {activeTab === "fetches" && (
- {renderFetchHistory()} -
)} - - - - - Clear Download History? - - This will remove all entries from your download history. This action cannot be undone. - Note: The actual downloaded files will NOT be deleted. - - - - - - - - - - - - - Clear {activeFetchTab.charAt(0).toUpperCase() + activeFetchTab.slice(1)} History? - - This will remove all {activeFetchTab} entries from your fetch history cache. - - - - - - - - -
); -} diff --git a/frontend/src/components/OtherProjects.tsx b/frontend/src/components/OtherProjects.tsx deleted file mode 100644 index ba75fb0..0000000 --- a/frontend/src/components/OtherProjects.tsx +++ /dev/null @@ -1,322 +0,0 @@ -import { useEffect, useState } from "react"; -import { openExternal } from "@/lib/utils"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@/components/ui/card"; -import { Star, GitFork, Clock, Download, Info } from "lucide-react"; -import AudioTTSProIcon from "@/assets/audiotts-pro.webp"; -import ChatGPTTTSIcon from "@/assets/chatgpt-tts.webp"; -import XIcon from "@/assets/x.webp"; -import SpotubeDLIcon from "@/assets/icons/spotubedl.svg"; -import XBatchDLIcon from "@/assets/icons/xbatchdl.svg"; -import SpotiFLACNextIcon from "@/assets/icons/next.svg"; -import { langColors } from "@/assets/github-lang-colors"; -const browserExtensionItems = [ - { icon: AudioTTSProIcon, label: "AudioTTS Pro", alt: "AudioTTS Pro" }, - { icon: ChatGPTTTSIcon, label: "ChatGPT TTS", alt: "ChatGPT TTS" }, - { icon: XIcon, label: "Twitter/X Media Batch Downloader", alt: "Twitter/X Media Batch Downloader" }, -]; -const projectCardClass = "cursor-pointer gap-3 py-5 transition-colors hover:bg-muted/50 dark:hover:bg-accent/50"; -const projectCardHeaderClass = "px-5 gap-1.5"; -const projectCardContentClass = "px-5"; -const projectBodyClass = "text-[13px] leading-snug"; -const releaseMetaClass = "text-xs text-muted-foreground whitespace-nowrap"; -const releaseVersionClass = "text-xs bg-primary text-primary-foreground px-1.5 py-0.5 rounded-sm font-mono font-semibold whitespace-nowrap"; -export function OtherProjects() { - const [repoStats, setRepoStats] = useState>({}); - useEffect(() => { - const fetchRepoStats = async () => { - const CACHE_KEY = "github_repo_stats_v4"; - const CACHE_DURATION = 1000 * 60 * 60; - const cached = localStorage.getItem(CACHE_KEY); - if (cached) { - try { - const { data, timestamp } = JSON.parse(cached); - if (Date.now() - timestamp < CACHE_DURATION) { - setRepoStats(data); - return; - } - } - catch (err) { - console.error("Failed to parse cache:", err); - } - } - const repos = [ - { name: "SpotiFLAC-Next", owner: "spotbye" }, - { name: "Twitter-X-Media-Batch-Downloader", owner: "afkarxyz" }, - ]; - const stats: Record = {}; - for (const repo of repos) { - try { - 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}/releases`), - fetch(`https://api.github.com/repos/${repo.owner}/${repo.name}/languages`), - ]); - if (repoRes.status === 403) { - if (cached) { - const { data } = JSON.parse(cached); - setRepoStats(data); - } - return; - } - if (repoRes.ok) { - const repoData = await repoRes.json(); - const releases = releasesRes.ok ? await releasesRes.json() : []; - const languages = langsRes.ok ? await langsRes.json() : {}; - let totalDownloads = 0; - let latestDownloads = 0; - let latestVersion = ""; - let latestReleaseAt = ""; - if (releases.length > 0) { - latestVersion = releases[0].tag_name || ""; - latestReleaseAt = releases[0].published_at || releases[0].created_at || ""; - latestDownloads = - releases[0].assets?.reduce((sum: number, asset: any) => sum + (asset.download_count || 0), 0) || 0; - totalDownloads = releases.reduce((sum: number, release: any) => { - return (sum + - (release.assets?.reduce((s: number, a: any) => s + (a.download_count || 0), 0) || 0)); - }, 0); - } - const topLangs = Object.entries(languages) - .sort(([, a]: any, [, b]: any) => b - a) - .slice(0, 4) - .map(([lang]) => lang); - stats[repo.name] = { - stars: repoData.stargazers_count, - forks: repoData.forks_count, - createdAt: repoData.created_at, - description: repoData.description, - totalDownloads, - latestDownloads, - latestVersion, - latestReleaseAt, - languages: topLangs, - }; - } - } - catch (err) { - console.error(`Failed to fetch stats for ${repo.name}:`, err); - if (cached) { - const { data } = JSON.parse(cached); - setRepoStats(data); - return; - } - } - } - setRepoStats(stats); - localStorage.setItem(CACHE_KEY, JSON.stringify({ data: stats, timestamp: Date.now() })); - }; - fetchRepoStats(); - }, []); - const formatTimeAgo = (dateString: string): string => { - const now = new Date(); - const updated = new Date(dateString); - const diffMs = now.getTime() - updated.getTime(); - const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); - const diffMonths = Math.floor(diffDays / 30); - if (diffDays === 0) - return "today"; - if (diffDays === 1) - return "1d"; - if (diffDays < 30) - return `${diffDays}d`; - if (diffMonths === 1) - return "1mo"; - if (diffMonths < 12) - return `${diffMonths}mo`; - const diffYears = Math.floor(diffMonths / 12); - return `${diffYears}y`; - }; - const formatReleaseTimeAgo = (dateString: string): string => { - if (!dateString) { - return ""; - } - const now = Date.now(); - const releasedAt = new Date(dateString).getTime(); - if (Number.isNaN(releasedAt)) { - return ""; - } - const diffMs = Math.max(0, now - releasedAt); - const totalMinutes = Math.floor(diffMs / (1000 * 60)); - const totalHours = Math.floor(totalMinutes / 60); - const totalDays = Math.floor(totalHours / 24); - const totalMonths = Math.floor(totalDays / 30); - const totalYears = Math.floor(totalMonths / 12); - if (totalYears > 0) { - const remainingMonths = totalMonths % 12; - return remainingMonths > 0 ? `${totalYears}y ${remainingMonths}m ago` : `${totalYears}y ago`; - } - if (totalMonths > 0) { - const remainingDays = totalDays % 30; - return remainingDays > 0 ? `${totalMonths}m ${remainingDays}d ago` : `${totalMonths}m ago`; - } - if (totalDays > 0) { - const remainingHours = totalHours % 24; - return remainingHours > 0 ? `${totalDays}d ${remainingHours}h ago` : `${totalDays}d ago`; - } - if (totalHours > 0) { - const remainingMinutes = totalMinutes % 60; - return `${totalHours}h ${remainingMinutes}m ago`; - } - return `${totalMinutes}m ago`; - }; - const formatNumber = (num: number): string => { - if (num >= 1000) { - return num.toLocaleString(); - } - return num.toString(); - }; - const getLangColor = (lang: string): string => { - return langColors[lang] || "#858585"; - }; - const getRepoDescription = (repoName: string): string => { - return repoStats[repoName]?.description || ""; - }; - return (
-
-

Other Projects

-
- -
-
- openExternal("https://github.com/spotbye/SpotiFLAC-Next")}> - -
- SpotiFLAC Next -
- {repoStats["SpotiFLAC-Next"]?.latestReleaseAt && ( - {formatReleaseTimeAgo(repoStats["SpotiFLAC-Next"].latestReleaseAt)} - )} - {repoStats["SpotiFLAC-Next"]?.latestVersion && ( - {repoStats["SpotiFLAC-Next"].latestVersion} - )} -
-
- - SpotiFLAC Next - - - {getRepoDescription("SpotiFLAC-Next")} - -
- {repoStats["SpotiFLAC-Next"] && ( - {repoStats["SpotiFLAC-Next"].languages?.length > 0 && (
- {repoStats["SpotiFLAC-Next"].languages.map((lang: string) => ( - {lang} - ))} -
)} -
- - {" "} - {formatNumber(repoStats["SpotiFLAC-Next"].stars)} - - - {" "} - {repoStats["SpotiFLAC-Next"].forks} - - - {" "} - {formatTimeAgo(repoStats["SpotiFLAC-Next"].createdAt)} - -
-
-
- - Note -
-

- This project released as a token of appreciation for those who have supported SpotiFLAC through Ko-fi, Patreon, or crypto. It’s not a paid product, but it’s shared privately through a supporter-only post. -

-
-
)} -
- openExternal("https://github.com/afkarxyz/Twitter-X-Media-Batch-Downloader")}> - -
- Twitter/X Media Batch Downloader -
- {repoStats["Twitter-X-Media-Batch-Downloader"]?.latestReleaseAt && ( - {formatReleaseTimeAgo(repoStats["Twitter-X-Media-Batch-Downloader"].latestReleaseAt)} - )} - {repoStats["Twitter-X-Media-Batch-Downloader"]?.latestVersion && ( - {repoStats["Twitter-X-Media-Batch-Downloader"].latestVersion} - )} -
-
- - Twitter/X Media Batch Downloader - - - {getRepoDescription("Twitter-X-Media-Batch-Downloader")} - -
- {repoStats["Twitter-X-Media-Batch-Downloader"] && ( -
- {repoStats["Twitter-X-Media-Batch-Downloader"].languages?.map((lang: string) => ( - {lang} - ))} -
-
- - {" "} - {formatNumber(repoStats["Twitter-X-Media-Batch-Downloader"].stars)} - - - {" "} - {repoStats["Twitter-X-Media-Batch-Downloader"].forks} - - - {" "} - {formatTimeAgo(repoStats["Twitter-X-Media-Batch-Downloader"] - .createdAt)} - -
-
- - TOTAL:{" "} - {formatNumber(repoStats["Twitter-X-Media-Batch-Downloader"] - .totalDownloads)} - - - LATEST:{" "} - {formatNumber(repoStats["Twitter-X-Media-Batch-Downloader"] - .latestDownloads)} - -
-
)} -
-
- openExternal("https://exyezed.fyi/")}> - - Browser Extensions & Scripts - - {browserExtensionItems.map((item) => (
- {item.alt}/ - - {item.label} - -
))} -
-
-
- openExternal("https://spotubedl.com/")}> - - - SpotubeDL{" "} - SpotubeDL.com - - - Download Spotify Tracks, Albums, Playlists & Discography as MP3/OGG/Opus. - - - -
-
-
-
); -} diff --git a/frontend/src/components/PlatformIcons.tsx b/frontend/src/components/PlatformIcons.tsx deleted file mode 100644 index 388e1c6..0000000 --- a/frontend/src/components/PlatformIcons.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import amazonMusicIcon from "../assets/icons/amzn.png"; -import appleMusicIcon from "../assets/icons/am.png"; -import deezerIcon from "../assets/icons/dzr.png"; -import lrclibIcon from "../assets/icons/lrclib.png"; -import musicBrainzDarkIcon from "../assets/icons/musicbrainz_d.png"; -import musicBrainzLightIcon from "../assets/icons/musicbrainz_l.png"; -import qobuzIcon from "../assets/icons/qbz.png"; -import songlinkDarkIcon from "../assets/icons/songlink_d.png"; -import songlinkLightIcon from "../assets/icons/songlink_l.png"; -import songstatsIcon from "../assets/icons/songstats.png"; -import tidalDarkIcon from "../assets/icons/tidal_d.png"; -import tidalLightIcon from "../assets/icons/tidal_l.png"; -type PlatformIconProps = { - className?: string; -}; -function sanitizeClassName(className: string): string { - return className - .split(/\s+/) - .filter(Boolean) - .filter((part) => part !== "fill-current" && part !== "fill-muted-foreground" && !part.startsWith("text-")) - .join(" "); -} -function hasRoundedClass(className: string): boolean { - return className - .split(/\s+/) - .some((part) => part.startsWith("rounded")); -} -function getStatusClasses(className: string): string { - if (className.includes("text-green-500")) { - return "ring-2 ring-green-500 rounded-sm"; - } - if (className.includes("text-red-500")) { - return "ring-2 ring-red-500 rounded-sm opacity-70"; - } - return ""; -} -function PlatformIcon({ src, alt, className = "w-4 h-4", defaultClassName = "" }: { - src: string; - alt: string; - className?: string; - defaultClassName?: string; -}) { - const cleanedClassName = sanitizeClassName(className); - const statusClasses = getStatusClasses(className); - const imageClassName = [ - cleanedClassName || "w-4 h-4", - "inline-block shrink-0 object-contain", - !hasRoundedClass(cleanedClassName) ? defaultClassName : "", - statusClasses, - ] - .filter(Boolean) - .join(" "); - return {alt}; -} -function ThemedPlatformIcon({ lightSrc, darkSrc, alt, className = "w-4 h-4", defaultClassName = "" }: { - lightSrc: string; - darkSrc: string; - alt: string; - className?: string; - defaultClassName?: string; -}) { - const cleanedClassName = sanitizeClassName(className); - const statusClasses = getStatusClasses(className); - const wrapperClassName = [ - cleanedClassName || "w-4 h-4", - "relative inline-flex shrink-0", - !hasRoundedClass(cleanedClassName) ? defaultClassName : "", - statusClasses, - ] - .filter(Boolean) - .join(" "); - return - - - ; -} -export function TidalIcon({ className = "w-4 h-4" }: PlatformIconProps) { - return ; -} -export function QobuzIcon({ className = "w-4 h-4" }: PlatformIconProps) { - return ; -} -export function AmazonIcon({ className = "w-4 h-4" }: PlatformIconProps) { - return ; -} -export function AppleMusicIcon({ className = "w-4 h-4" }: PlatformIconProps) { - return ; -} -export function DeezerIcon({ className = "w-4 h-4" }: PlatformIconProps) { - return ; -} -export function LrclibIcon({ className = "w-4 h-4" }: PlatformIconProps) { - return ; -} -export function MusicBrainzIcon({ className = "w-4 h-4" }: PlatformIconProps) { - return ; -} -export function SonglinkIcon({ className = "w-4 h-4" }: PlatformIconProps) { - return ; -} -export function SongstatsIcon({ className = "w-4 h-4" }: PlatformIconProps) { - return ; -} -export function TidalAvailabilityIcon({ className = "w-4 h-4" }: PlatformIconProps) { - return - - - ; -} -export function QobuzAvailabilityIcon({ className = "w-4 h-4" }: PlatformIconProps) { - return - - - ; -} -export function AmazonAvailabilityIcon({ className = "w-4 h-4" }: PlatformIconProps) { - return - - - ; -} diff --git a/frontend/src/components/PlaylistInfo.tsx b/frontend/src/components/PlaylistInfo.tsx deleted file mode 100644 index dc7cd29..0000000 --- a/frontend/src/components/PlaylistInfo.tsx +++ /dev/null @@ -1,249 +0,0 @@ -import { Button } from "@/components/ui/button"; -import { Card, CardContent } from "@/components/ui/card"; -import { Download, FolderOpen, ImageDown, FileText, XCircle } from "lucide-react"; -import { Spinner } from "@/components/ui/spinner"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { SearchAndSort } from "./SearchAndSort"; -import { TrackList } from "./TrackList"; -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 { buildPlaylistFolderName } from "@/lib/playlist"; -import type { TrackMetadata, TrackAvailability } from "@/types/api"; -interface PlaylistInfoProps { - playlistInfo: { - owner: { - name: string; - display_name: string; - images: string; - }; - tracks: { - total: number; - }; - followers: { - total: number; - }; - cover?: string; - description?: string; - }; - trackList: TrackMetadata[]; - searchQuery: string; - sortBy: string; - selectedTracks: string[]; - downloadedTracks: Set; - failedTracks: Set; - skippedTracks: Set; - downloadingTrack: string | null; - isDownloading: boolean; - bulkDownloadType: "all" | "selected" | null; - downloadProgress: number; - downloadRemainingCount: number; - currentDownloadInfo: { - name: string; - artists: string; - } | null; - currentPage: number; - itemsPerPage: number; - downloadedLyrics?: Set; - failedLyrics?: Set; - skippedLyrics?: Set; - downloadingLyricsTrack?: string | null; - checkingAvailabilityTrack?: string | null; - availabilityMap?: Map; - downloadedCovers?: Set; - failedCovers?: Set; - skippedCovers?: Set; - downloadingCoverTrack?: string | null; - isBulkDownloadingCovers?: boolean; - isBulkDownloadingLyrics?: boolean; - isMetadataLoading?: boolean; - onSearchChange: (value: string) => void; - onSortChange: (value: string) => void; - onToggleTrack: (id: string) => void; - onToggleSelectAll: (tracks: TrackMetadata[]) => 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; - 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; - onDownloadAllLyrics?: () => void; - onDownloadAllCovers?: () => void; - onDownloadAll: () => void; - onDownloadSelected: () => void; - onStopDownload: () => void; - onOpenFolder: () => void; - onPageChange: (page: number) => void; - onAlbumClick: (album: { - id: string; - name: string; - external_urls: string; - }) => void; - onArtistClick: (artist: { - id: string; - name: string; - external_urls: string; - }) => void; - onTrackClick: (track: TrackMetadata) => void; - onBack?: () => void; -} -export function PlaylistInfo({ playlistInfo, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, downloadRemainingCount, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, isMetadataLoading = false, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onPageChange, onAlbumClick, onArtistClick, onTrackClick, onBack, }: PlaylistInfoProps) { - const settings = getSettings(); - const playlistName = playlistInfo.owner.name; - const playlistFolderName = buildPlaylistFolderName(playlistName, playlistInfo.owner.display_name, settings.playlistOwnerFolderName); - const [downloadingPlaylistCover, setDownloadingPlaylistCover] = useState(false); - const fetchedTrackCount = trackList.length; - const totalTrackCount = playlistInfo.tracks.total; - const showStreamingProgress = isMetadataLoading && totalTrackCount > 0 && fetchedTrackCount < totalTrackCount; - const handleDownloadPlaylistCover = async () => { - if (!playlistInfo.cover) - return; - setDownloadingPlaylistCover(true); - try { - const os = settings.operatingSystem; - let outputDir = settings.downloadPath; - const placeholder = "__SLASH_PLACEHOLDER__"; - const templateData: TemplateData = { - artist: "", - album: "", - album_artist: "", - title: playlistName.replace(/\//g, placeholder), - playlist: playlistFolderName.replace(/\//g, placeholder), - }; - if (settings.createPlaylistFolder && playlistFolderName) { - outputDir = joinPath(os, outputDir, sanitizePath(playlistFolderName.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 (
- - {onBack && (
- -
)} - -
- {playlistInfo.cover && (
- {playlistName} -
- - - - -

Download Separate Playlist Cover

-
-
-
)} -
-
-

Playlist

-

{playlistName}

- {playlistInfo.description && (

{playlistInfo.description}

)} -
-
- {playlistInfo.owner.images && ({playlistInfo.owner.display_name})} - {playlistInfo.owner.display_name} -
- - - {showStreamingProgress - ? `${fetchedTrackCount.toLocaleString()} / ${totalTrackCount.toLocaleString()} tracks` - : `${Math.max(totalTrackCount, fetchedTrackCount).toLocaleString()} ${Math.max(totalTrackCount, fetchedTrackCount) === 1 ? "track" : "tracks"}`} - - - {playlistInfo.followers.total.toLocaleString()} {playlistInfo.followers.total === 1 ? "follower" : "followers"} -
-
-
- - {selectedTracks.length > 0 && ()} - {onDownloadAllLyrics && ( - - - - -

Download All Lyrics

-
-
)} - {onDownloadAllCovers && ( - - - - -

Download All Separate Covers

-
-
)} - {downloadedTracks.size > 0 && ( - - - - -

Open Folder

-
-
)} -
- {isDownloading && ()} -
-
-
-
-
- - -
-
); -} diff --git a/frontend/src/components/SearchAndSort.tsx b/frontend/src/components/SearchAndSort.tsx deleted file mode 100644 index 29a0559..0000000 --- a/frontend/src/components/SearchAndSort.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { Input } from "@/components/ui/input"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { Search, ArrowUpDown, XCircle } from "lucide-react"; -interface SearchAndSortProps { - searchQuery: string; - sortBy: string; - onSearchChange: (value: string) => void; - onSortChange: (value: string) => void; -} -export function SearchAndSort({ searchQuery, sortBy, onSearchChange, onSortChange, }: SearchAndSortProps) { - return (
-
- - onSearchChange(e.target.value)} className="pl-10 pr-8"/> - {searchQuery && ()} -
- -
); -} diff --git a/frontend/src/components/SearchBar.tsx b/frontend/src/components/SearchBar.tsx deleted file mode 100644 index 8ee2e50..0000000 --- a/frontend/src/components/SearchBar.tsx +++ /dev/null @@ -1,817 +0,0 @@ -import { useState, useEffect, useRef, useMemo } from "react"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { InputWithContext } from "@/components/ui/input-with-context"; -import { CloudDownload, XCircle, Link, Search, X, ChevronDown, ArrowUpDown, } from "lucide-react"; -import { Spinner } from "@/components/ui/spinner"; -import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; -import { FetchHistory } from "@/components/FetchHistory"; -import type { HistoryItem } from "@/components/FetchHistory"; -import { SearchSpotify, SearchSpotifyByType } from "../../wailsjs/go/main/App"; -import { backend } from "../../wailsjs/go/models"; -import { cn } from "@/lib/utils"; -import { useTypingEffect } from "@/hooks/useTypingEffect"; -import { getSettings, type Settings } from "@/lib/settings"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -const FETCH_PLACEHOLDERS = [ - "https://open.spotify.com/track/...", - "https://open.spotify.com/album/...", - "https://open.spotify.com/playlist/...", - "https://open.spotify.com/artist/...", -]; -const SEARCH_PLACEHOLDERS = [ - "Golden", - "Taylor Swift", - "The Weeknd", - "Starboy", - "Joji", - "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 regionNames = new Intl.DisplayNames(["en"], { type: "region" }); -const getRegionName = (code: string) => { - try { - if (code === "XK") - return "Kosovo"; - return regionNames.of(code) || code; - } - catch (e) { - return code; - } -}; -type ResultTab = "tracks" | "albums" | "artists" | "playlists"; -const RECENT_SEARCHES_KEY = "spotiflac_recent_searches"; -const MAX_RECENT_SEARCHES = 8; -const SEARCH_LIMIT = 50; -interface SearchBarProps { - url: string; - loading: boolean; - onUrlChange: (url: string) => void; - onFetch: () => void; - onFetchUrl: (url: string) => Promise; - history: HistoryItem[]; - onHistorySelect: (item: HistoryItem) => void; - onHistoryRemove: (id: string) => void; - hasResult: boolean; - searchMode: boolean; - onSearchModeChange: (isSearch: boolean) => void; - region: string; - onRegionChange: (region: string) => void; -} -export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, history, onHistorySelect, onHistoryRemove, hasResult, searchMode, onSearchModeChange, region, onRegionChange, }: SearchBarProps) { - const [searchQuery, setSearchQuery] = useState(""); - const [searchResults, setSearchResults] = useState(null); - const [showRegionSelector, setShowRegionSelector] = useState(() => getSettings().linkResolver === "songlink"); - const [resultFilter, setResultFilter] = useState(""); - const [sortOrders, setSortOrders] = useState>({ - tracks: "default", - albums: "default", - artists: "default", - playlists: "default", - }); - const [isSearching, setIsSearching] = useState(false); - const [isLoadingMore, setIsLoadingMore] = useState(false); - const [lastSearchedQuery, setLastSearchedQuery] = useState(""); - const [activeTab, setActiveTab] = useState("tracks"); - const [recentSearches, setRecentSearches] = useState([]); - const [hasMore, setHasMore] = useState>({ - tracks: false, - albums: false, - artists: false, - playlists: false, - }); - const [showInvalidUrlDialog, setShowInvalidUrlDialog] = useState(false); - const [invalidUrl, setInvalidUrl] = useState(""); - const searchTimeoutRef = useRef | null>(null); - const placeholders = searchMode ? SEARCH_PLACEHOLDERS : FETCH_PLACEHOLDERS; - const placeholderText = useTypingEffect(placeholders); - useEffect(() => { - try { - const saved = localStorage.getItem(RECENT_SEARCHES_KEY); - if (saved) { - setRecentSearches(JSON.parse(saved)); - } - } - catch (error) { - console.error("Failed to load recent searches:", error); - } - }, []); - useEffect(() => { - const syncRegionVisibility = (settings?: Partial) => { - const resolver = settings?.linkResolver ?? getSettings().linkResolver; - setShowRegionSelector(resolver === "songlink"); - }; - syncRegionVisibility(); - const handleSettingsUpdate = (event: Event) => { - syncRegionVisibility((event as CustomEvent>).detail); - }; - window.addEventListener("settingsUpdated", handleSettingsUpdate); - return () => window.removeEventListener("settingsUpdated", handleSettingsUpdate); - }, []); - const saveRecentSearch = (query: string) => { - const trimmed = query.trim(); - if (!trimmed) - return; - setRecentSearches((prev) => { - const filtered = prev.filter((s) => s.toLowerCase() !== trimmed.toLowerCase()); - const updated = [trimmed, ...filtered].slice(0, MAX_RECENT_SEARCHES); - try { - localStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(updated)); - } - catch (error) { - console.error("Failed to save recent searches:", error); - } - return updated; - }); - }; - const removeRecentSearch = (query: string) => { - setRecentSearches((prev) => { - const updated = prev.filter((s) => s !== query); - try { - localStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(updated)); - } - catch (error) { - console.error("Failed to save recent searches:", error); - } - return updated; - }); - }; - useEffect(() => { - if (!searchMode || !searchQuery.trim()) { - return; - } - if (searchQuery.trim() === lastSearchedQuery) { - return; - } - if (searchTimeoutRef.current) { - clearTimeout(searchTimeoutRef.current); - } - searchTimeoutRef.current = setTimeout(async () => { - setIsSearching(true); - try { - const results = await SearchSpotify({ - query: searchQuery, - limit: SEARCH_LIMIT, - }); - setSearchResults(results); - setResultFilter(""); - setLastSearchedQuery(searchQuery.trim()); - saveRecentSearch(searchQuery.trim()); - setHasMore({ - tracks: results.tracks.length === SEARCH_LIMIT, - albums: results.albums.length === SEARCH_LIMIT, - artists: results.artists.length === SEARCH_LIMIT, - playlists: results.playlists.length === SEARCH_LIMIT, - }); - if (results.tracks.length > 0) - setActiveTab("tracks"); - else if (results.albums.length > 0) - setActiveTab("albums"); - else if (results.artists.length > 0) - setActiveTab("artists"); - else if (results.playlists.length > 0) - setActiveTab("playlists"); - } - catch (error) { - console.error("Search failed:", error); - setSearchResults(null); - } - finally { - setIsSearching(false); - } - }, 400); - return () => { - if (searchTimeoutRef.current) { - clearTimeout(searchTimeoutRef.current); - } - }; - }, [searchQuery, searchMode, lastSearchedQuery]); - const handleLoadMore = async () => { - if (!searchResults || !lastSearchedQuery || isLoadingMore) - return; - const typeMap: Record = { - tracks: "track", - albums: "album", - artists: "artist", - playlists: "playlist", - }; - const currentCount = getTabCount(activeTab); - setIsLoadingMore(true); - try { - const moreResults = await SearchSpotifyByType({ - query: lastSearchedQuery, - search_type: typeMap[activeTab], - limit: SEARCH_LIMIT, - offset: currentCount, - }); - if (moreResults.length > 0) { - setSearchResults((prev) => { - if (!prev) - return prev; - const updated = new backend.SearchResponse({ - tracks: activeTab === "tracks" - ? [...prev.tracks, ...moreResults] - : prev.tracks, - 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; - }); - } - setHasMore((prev) => ({ - ...prev, - [activeTab]: moreResults.length === SEARCH_LIMIT, - })); - } - catch (error) { - console.error("Load more failed:", error); - } - finally { - 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) => { - 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) => { - onSearchModeChange(false); - onFetchUrl(externalUrl); - }; - const formatDuration = (ms: number) => { - const minutes = Math.floor(ms / 60000); - const seconds = Math.floor((ms % 60000) / 1000); - return `${minutes}:${seconds.toString().padStart(2, "0")}`; - }; - const hasAnyResults = searchResults && - (searchResults.tracks.length > 0 || - searchResults.albums.length > 0 || - searchResults.artists.length > 0 || - searchResults.playlists.length > 0); - const getTabCount = (tab: ResultTab): number => { - if (!searchResults) - return 0; - switch (tab) { - case "tracks": - return searchResults.tracks.length; - case "albums": - 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: { - key: ResultTab; - label: string; - }[] = [ - { key: "tracks", label: "Tracks" }, - { key: "albums", label: "Albums" }, - { key: "artists", label: "Artists" }, - { key: "playlists", label: "Playlists" }, - ]; - return (
-
- - - - - -

{searchMode ? "Fetch Mode" : "Search Mode"}

-
-
- -
- {!searchMode ? (<> - onUrlChange(e.target.value)} onPaste={handlePaste} onKeyDown={(e) => e.key === "Enter" && handleFetchWithValidation()} className="pr-8"/> - {url && ()} - ) : (<> - setSearchQuery(e.target.value)} className="pr-8"/> - {searchQuery && ()} - )} -
- - {!searchMode && (<> - {showRegionSelector && ()} - - )} -
- - {!searchMode && !hasResult && ()} - - {searchMode && (
- {!searchQuery && !searchResults && recentSearches.length > 0 && (
-

Recent Searches

-
- {recentSearches.map((query) => (
setSearchQuery(query)}> - {query} - -
))} -
-
)} - - {isSearching && (
- - Searching... -
)} - - {!isSearching && searchQuery && !hasAnyResults && (
- No results found for "{searchQuery}" -
)} - - {!isSearching && hasAnyResults && (<> -
- {tabs.map((tab) => { - const count = getTabCount(tab.key); - if (count === 0) - return null; - return (); - })} -
- -
-
- - setResultFilter(e.target.value)} className="pl-10 pr-8"/> - {resultFilter && ()} -
- -
- -
- {activeTab === "tracks" && - sortedResults.tracks.map((track) => ())} - - {activeTab === "albums" && - sortedResults.albums.map((album) => ())} - - {activeTab === "artists" && - sortedResults.artists.map((artist) => ())} - - {activeTab === "playlists" && - sortedResults.playlists.map((playlist) => ())} -
- - {hasMore[activeTab] && (
- -
)} - )} -
)} - - - - - Invalid URL - - Only Spotify links are allowed in Fetch mode. - - - - {invalidUrl && (
- {invalidUrl} -
)} - - - - - -
-
-
); -} diff --git a/frontend/src/components/SettingsPage.tsx b/frontend/src/components/SettingsPage.tsx deleted file mode 100644 index d032be6..0000000 --- a/frontend/src/components/SettingsPage.tsx +++ /dev/null @@ -1,1003 +0,0 @@ -import { useState, useEffect, useCallback } from "react"; -import { flushSync } from "react-dom"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { InputWithContext } from "@/components/ui/input-with-context"; -import { Label } from "@/components/ui/label"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; -import { FolderOpen, Save, RotateCcw, Info, ArrowRight, MonitorCog, FolderCog, Router, FolderLock, Plus, Trash2, ExternalLink, PlugZap, Download, Tags } from "lucide-react"; -import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { Switch } from "@/components/ui/switch"; -import { getSettings, getSettingsWithDefaults, saveSettings, resetToDefaultSettings, applyThemeMode, applyFont, getFontOptions, parseGoogleFontUrl, loadGoogleFontUrl, loadCustomFonts, saveCustomFonts, FOLDER_PRESETS, FILENAME_PRESETS, TEMPLATE_VARIABLES, hasConfiguredCustomTidalApi, sanitizeAutoOrder, type Settings as SettingsType, type FontFamily, type CustomFontFamily, type FolderPreset, type FilenamePreset, type ExistingFileCheckMode, } from "@/lib/settings"; -import { themes, applyTheme } from "@/lib/themes"; -import { SelectFolder, OpenConfigFolder, CheckCustomTidalAPI } from "../../wailsjs/go/main/App"; -import { toastWithSound as toast } from "@/lib/toast-with-sound"; -import { openExternal } from "@/lib/utils"; -import { ApiStatusTab } from "./ApiStatusTab"; -import { AmazonIcon, QobuzIcon, SonglinkIcon, SongstatsIcon, TidalIcon } from "./PlatformIcons"; -interface SettingsPageProps { - onUnsavedChangesChange?: (hasUnsavedChanges: boolean) => void; - onResetRequest?: (resetFn: () => void) => void; -} -type CustomTidalApiStatus = "idle" | "checking" | "online" | "offline"; -export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: SettingsPageProps) { - const [savedSettings, setSavedSettings] = useState(getSettings()); - const [tempSettings, setTempSettings] = useState(savedSettings); - const [isDark, setIsDark] = useState(document.documentElement.classList.contains("dark")); - const [showResetConfirm, setShowResetConfirm] = useState(false); - const [showAddFontDialog, setShowAddFontDialog] = useState(false); - const [showCustomTidalApiDialog, setShowCustomTidalApiDialog] = useState(false); - const [addFontUrl, setAddFontUrl] = useState(""); - const [customTidalApiStatus, setCustomTidalApiStatus] = useState("idle"); - const parsedAddFont = parseGoogleFontUrl(addFontUrl); - const fontOptions = getFontOptions(tempSettings.customFonts); - const hasUnsavedChanges = JSON.stringify(savedSettings) !== JSON.stringify(tempSettings); - const hasCustomTidalInstanceConfigured = hasConfiguredCustomTidalApi(tempSettings.customTidalApi); - const effectiveDownloader = !hasCustomTidalInstanceConfigured && tempSettings.downloader === "tidal" - ? "auto" - : tempSettings.downloader; - const effectiveAutoOrder = sanitizeAutoOrder(tempSettings.autoOrder, hasCustomTidalInstanceConfigured); - const resetToSaved = useCallback(() => { - const freshSavedSettings = getSettings(); - flushSync(() => { - setTempSettings(freshSavedSettings); - setIsDark(document.documentElement.classList.contains("dark")); - }); - }, []); - useEffect(() => { - if (onResetRequest) { - onResetRequest(resetToSaved); - } - }, [onResetRequest, resetToSaved]); - useEffect(() => { - onUnsavedChangesChange?.(hasUnsavedChanges); - }, [hasUnsavedChanges, onUnsavedChangesChange]); - useEffect(() => { - applyThemeMode(savedSettings.themeMode); - applyTheme(savedSettings.theme); - const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); - const handleChange = () => { - if (savedSettings.themeMode === "auto") { - applyThemeMode("auto"); - applyTheme(savedSettings.theme); - } - }; - mediaQuery.addEventListener("change", handleChange); - return () => mediaQuery.removeEventListener("change", handleChange); - }, [savedSettings.themeMode, savedSettings.theme]); - useEffect(() => { - applyThemeMode(tempSettings.themeMode); - applyTheme(tempSettings.theme); - applyFont(tempSettings.fontFamily, tempSettings.customFonts); - setTimeout(() => { - setIsDark(document.documentElement.classList.contains("dark")); - }, 0); - }, [tempSettings.themeMode, tempSettings.theme, tempSettings.fontFamily, tempSettings.customFonts]); - useEffect(() => { - if (showAddFontDialog && parsedAddFont) { - loadGoogleFontUrl(parsedAddFont.url, "spotiflac-add-font-preview"); - } - }, [showAddFontDialog, parsedAddFont]); - useEffect(() => { - const loadDefaults = async () => { - const currentSettings = getSettings(); - if (!currentSettings.downloadPath) { - const settingsWithDefaults = await getSettingsWithDefaults(); - setSavedSettings(settingsWithDefaults); - setTempSettings(settingsWithDefaults); - await saveSettings(settingsWithDefaults); - } - }; - loadDefaults(); - }, []); - useEffect(() => { - const syncCustomFonts = async () => { - const customFonts = await loadCustomFonts(); - setSavedSettings((prev) => ({ ...prev, customFonts })); - setTempSettings((prev) => ({ ...prev, customFonts })); - }; - void syncCustomFonts(); - }, []); - const handleSave = async () => { - await saveSettings(tempSettings); - const persistedSettings = getSettings(); - setSavedSettings(persistedSettings); - setTempSettings(persistedSettings); - toast.success("Settings saved"); - onUnsavedChangesChange?.(false); - }; - const handleReset = async () => { - const defaultSettings = await resetToDefaultSettings(); - setTempSettings(defaultSettings); - setSavedSettings(defaultSettings); - applyThemeMode(defaultSettings.themeMode); - applyTheme(defaultSettings.theme); - applyFont(defaultSettings.fontFamily, defaultSettings.customFonts); - setShowResetConfirm(false); - toast.success("Settings reset to default"); - }; - const handleBrowseFolder = async () => { - try { - const selectedPath = await SelectFolder(tempSettings.downloadPath || ""); - if (selectedPath && selectedPath.trim() !== "") { - setTempSettings((prev) => ({ ...prev, downloadPath: selectedPath })); - } - } - catch (error) { - console.error("Error selecting folder:", error); - toast.error(`Error selecting folder: ${error}`); - } - }; - const closeAddFontDialog = () => { - setShowAddFontDialog(false); - setAddFontUrl(""); - }; - const handleAddFont = async () => { - if (!parsedAddFont) { - toast.error("Enter a valid Google Fonts URL"); - return; - } - const existingFonts = tempSettings.customFonts || []; - const existingIndex = existingFonts.findIndex((font) => font.value === parsedAddFont.value || font.url === parsedAddFont.url); - const customFonts = existingIndex >= 0 - ? existingFonts.map((font, index) => index === existingIndex ? parsedAddFont : font) - : [...existingFonts, parsedAddFont]; - const savedCustomFonts = await saveCustomFonts(customFonts); - setSavedSettings((prev) => ({ ...prev, customFonts: savedCustomFonts })); - setTempSettings((prev) => ({ - ...prev, - customFonts: savedCustomFonts, - fontFamily: parsedAddFont.value, - })); - closeAddFontDialog(); - toast.success(`${parsedAddFont.label} added`); - }; - const handleDeleteCustomFont = async (fontValue: CustomFontFamily) => { - const customFonts = (tempSettings.customFonts || []).filter((font) => font.value !== fontValue); - const savedCustomFonts = await saveCustomFonts(customFonts); - const shouldResetSavedFont = savedSettings.fontFamily === fontValue; - const shouldResetTempFont = tempSettings.fontFamily === fontValue; - const nextSavedSettings: SettingsType = { - ...savedSettings, - customFonts: savedCustomFonts, - fontFamily: shouldResetSavedFont ? "google-sans" : savedSettings.fontFamily, - }; - setSavedSettings(nextSavedSettings); - setTempSettings((prev) => ({ - ...prev, - customFonts: savedCustomFonts, - fontFamily: shouldResetTempFont ? "google-sans" : prev.fontFamily, - })); - if (shouldResetSavedFont) { - await saveSettings(nextSavedSettings); - } - toast.success("Font deleted"); - }; - const handleTidalQualityChange = async (value: "LOSSLESS" | "HI_RES_LOSSLESS") => { - setTempSettings((prev) => ({ ...prev, tidalQuality: value })); - }; - const handleQobuzQualityChange = (value: "6" | "7" | "27") => { - setTempSettings((prev) => ({ ...prev, qobuzQuality: value })); - }; - const handleAutoQualityChange = async (value: "16" | "24") => { - setTempSettings((prev) => ({ ...prev, autoQuality: value })); - }; - const persistCustomTidalApi = useCallback(async (nextValue: string) => { - const normalizedValue = nextValue.trim().replace(/\/+$/g, ""); - const persistedSettings = getSettings(); - const nextSavedSettings: SettingsType = { - ...persistedSettings, - customTidalApi: normalizedValue, - }; - await saveSettings(nextSavedSettings); - const nextSavedState = getSettings(); - setSavedSettings(nextSavedState); - setTempSettings((prev) => ({ - ...prev, - customTidalApi: nextSavedState.customTidalApi, - downloader: !hasConfiguredCustomTidalApi(nextSavedState.customTidalApi) && prev.downloader === "tidal" - ? nextSavedState.downloader - : prev.downloader, - autoOrder: sanitizeAutoOrder(prev.autoOrder, hasConfiguredCustomTidalApi(nextSavedState.customTidalApi)), - })); - }, []); - const handleCheckCustomTidalApi = async () => { - const normalizedCustomTidalApi = (tempSettings.customTidalApi || "").trim().replace(/\/+$/g, ""); - if (!normalizedCustomTidalApi.startsWith("https://")) { - toast.error("Enter a valid HTTPS HiFi API URL"); - return; - } - setCustomTidalApiStatus("checking"); - try { - const isOnline = await CheckCustomTidalAPI(normalizedCustomTidalApi); - setCustomTidalApiStatus(isOnline ? "online" : "offline"); - if (isOnline) { - toast.success("HiFi API instance is online"); - } - else { - toast.error("HiFi API instance is offline"); - } - } - catch (error) { - console.error("Failed to check custom Tidal API:", error); - setCustomTidalApiStatus("offline"); - toast.error(`Failed to check HiFi API instance: ${error}`); - } - }; - const [activeTab, setActiveTab] = useState<"general" | "download" | "files" | "metadata" | "status">("general"); - return (
-
-

Settings

-
- - - -
-
- -
- - - - - -
- -
- {activeTab === "general" && (
-
-
- - -
- -
- - -
-
- -
-
- -
- - -
-
- -
- setTempSettings((prev) => ({ - ...prev, - sfxEnabled: checked, - }))}/> - -
-
-
)} - - {activeTab === "download" && (
-
-
- -
- - {tempSettings.customTidalApi && ( - {tempSettings.customTidalApi} - )} -
-
- -
- -
- - - {effectiveDownloader === "auto" && (<> - - - - )} - - {effectiveDownloader === "tidal" && ()} - - {effectiveDownloader === "qobuz" && ()} - - {effectiveDownloader === "amazon" && (
- 16-bit - 24-bit/44.1kHz - 192kHz -
)} -
- - {((effectiveDownloader === "tidal" && - tempSettings.tidalQuality === "HI_RES_LOSSLESS") || - (effectiveDownloader === "qobuz" && - tempSettings.qobuzQuality === "27") || - (effectiveDownloader === "auto" && - tempSettings.autoQuality === "24")) && (
- setTempSettings((prev) => ({ - ...prev, - allowFallback: checked, - }))}/> - -
)} -
-
- -
-
- -
- -
-
- -
- setTempSettings((prev) => ({ - ...prev, - allowResolverFallback: checked, - }))}/> - -
-
-
)} - - {activeTab === "files" && (
-
-
- -
- setTempSettings((prev) => ({ - ...prev, - downloadPath: e.target.value, - }))} placeholder="C:\Users\YourUsername\Music"/> - -
-
- -
-
- - - - - - -

- Variables:{" "} - {TEMPLATE_VARIABLES.map((v) => v.key).join(", ")} -

-
-
-
-
- - {tempSettings.folderPreset === "custom" && ( setTempSettings((prev) => ({ - ...prev, - folderTemplate: e.target.value, - }))} placeholder="{artist}/{album}" className="h-9 text-sm flex-1"/>)} -
- {tempSettings.folderTemplate && (

- Preview:{" "} - - {tempSettings.folderTemplate - .replace(/\{artist\}/g, tempSettings.separator === "comma" ? "Kendrick Lamar, SZA" : "Kendrick Lamar; SZA") - .replace(/\{album\}/g, "Black Panther") - .replace(/\{album_artist\}/g, "Kendrick Lamar") - .replace(/\{title\}/g, "All The Stars") - .replace(/\{track\}/g, "01") - .replace(/\{disc\}/g, "1") - .replace(/\{year\}/g, "2018") - .replace(/\{date\}/g, "2018-02-09") - .replace(/\{isrc\}/g, "USUM71801234")} - / - -

)} -
- -
- setTempSettings((prev) => ({ - ...prev, - createPlaylistFolder: checked, - }))}/> - -
- -
- setTempSettings((prev) => ({ - ...prev, - playlistOwnerFolderName: checked, - }))}/> - -
- -
- setTempSettings((prev) => ({ - ...prev, - createM3u8File: checked, - }))}/> - -
-
- -
-
- - -
- -
-
- - - - - - -

- Variables:{" "} - {TEMPLATE_VARIABLES.map((v) => v.key).join(", ")} -

-
-
-
-
- - {tempSettings.filenamePreset === "custom" && ( setTempSettings((prev) => ({ - ...prev, - filenameTemplate: e.target.value, - }))} placeholder="{track}. {title}" className="h-9 text-sm flex-1"/>)} -
- {tempSettings.filenameTemplate && (

- Preview:{" "} - - {tempSettings.filenameTemplate - .replace(/\{artist\}/g, tempSettings.separator === "comma" ? "Kendrick Lamar, SZA" : "Kendrick Lamar; SZA") - .replace(/\{album_artist\}/g, "Kendrick Lamar") - .replace(/\{album\}/g, "Black Panther") - .replace(/\{title\}/g, "All The Stars") - .replace(/\{track\}/g, "01") - .replace(/\{disc\}/g, "1") - .replace(/\{year\}/g, "2018") - .replace(/\{date\}/g, "2018-02-09") - .replace(/\{isrc\}/g, "USUM71801234")} - .flac - -

)} -
- -
- - -
- -
- setTempSettings((prev) => ({ - ...prev, - redownloadWithSuffix: checked, - }))}/> - -
-
-
)} - - {activeTab === "metadata" && (
-
-
- setTempSettings((prev) => ({ - ...prev, - embedLyrics: checked, - }))}/> - -
- -
- setTempSettings((prev) => ({ - ...prev, - embedMaxQualityCover: checked, - }))}/> - -
- -
- setTempSettings((prev) => ({ - ...prev, - embedGenre: checked, - }))}/> - -
- - {tempSettings.embedGenre && (
- setTempSettings((prev) => ({ - ...prev, - useSingleGenre: checked, - }))}/> - -
)} -
- -
-
- setTempSettings((prev) => ({ - ...prev, - useFirstArtistOnly: checked, - }))}/> - -
-
-
)} - - {activeTab === "status" && ()} -
- - open ? setShowAddFontDialog(true) : closeAddFontDialog()}> - - -
- Add Font - -
- -
-
-
- - setAddFontUrl(event.target.value)} onKeyDown={(event) => { - if (event.key === "Enter" && parsedAddFont) { - void handleAddFont(); - } - }} placeholder="https://fonts.google.com/specimen/Ubuntu" autoFocus/> - {addFontUrl.trim() && !parsedAddFont && (

- Enter a valid Google Fonts URL. -

)} -
-
-

- Preview -

-

- Aa The quick brown fox -

-

- Kendrick Lamar - All The Stars -

-
-
- - - - -
-
- - - - -
- Tidal Source - -
- -
-
-
- -
- { - const nextValue = e.target.value.replace(/\/+$/g, ""); - setCustomTidalApiStatus("idle"); - void persistCustomTidalApi(nextValue); - }} placeholder="https://your-hifi-api.example"/> - - {tempSettings.customTidalApi && ()} -
-
- {customTidalApiStatus !== "idle" && (

- {customTidalApiStatus === "online" - ? "Custom HiFi API instance is online." - : customTidalApiStatus === "offline" - ? "Custom HiFi API instance is offline or returned preview-only data." - : "Checking custom HiFi API instance..."} -

)} -
- - - -
-
- - - - - Reset to Default? - - This will reset all settings to their default values. Your custom - font list will be kept. - - - - - - - - -
); -} diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx deleted file mode 100644 index 1554a08..0000000 --- a/frontend/src/components/Sidebar.tsx +++ /dev/null @@ -1,200 +0,0 @@ -import { useRef, useState, type RefObject } from "react"; -import { HomeIcon } from "@/components/ui/home"; -import { HistoryIcon } from "@/components/ui/history-icon"; -import { SettingsIcon } from "@/components/ui/settings"; -import { ActivityIcon, type ActivityIconHandle } from "@/components/ui/activity"; -import { TerminalIcon } from "@/components/ui/terminal"; -import { FileMusicIcon, type FileMusicIconHandle } from "@/components/ui/file-music"; -import { FilePenIcon, type FilePenIconHandle } from "@/components/ui/file-pen"; -import { BugReportIcon } from "@/components/ui/bug-report-icon"; -import { CoffeeIcon } from "@/components/ui/coffee"; -import { BlocksIcon } from "@/components/ui/blocks-icon"; -import { AudioLinesIcon, type AudioLinesIconHandle } from "@/components/ui/audio-lines"; -import { ToolCaseIcon } from "@/components/ui/tool-case"; -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 { Button } from "@/components/ui/button"; -import { openExternal } from "@/lib/utils"; -export type PageType = "main" | "settings" | "debug" | "audio-analysis" | "audio-converter" | "audio-resampler" | "file-manager" | "projects" | "support" | "history"; -interface SidebarProps { - currentPage: PageType; - onPageChange: (page: PageType) => void; -} -interface AnimatedIconHandle { - startAnimation: () => void; - stopAnimation: () => void; -} -export function Sidebar({ currentPage, onPageChange }: SidebarProps) { - const [isIssuesDialogOpen, setIsIssuesDialogOpen] = useState(false); - const [hasIssueAgreement, setHasIssueAgreement] = useState(false); - const analyzerIconRef = useRef(null); - const resamplerIconRef = useRef(null); - const converterIconRef = useRef(null); - const fileManagerIconRef = useRef(null); - const handleIssuesDialogChange = (open: boolean) => { - setIsIssuesDialogOpen(open); - if (!open) { - setHasIssueAgreement(false); - } - }; - const handleOpenIssues = () => { - openExternal("https://github.com/spotbye/SpotiFLAC/issues"); - handleIssuesDialogChange(false); - }; - const getAnimatedItemHandlers = (iconRef: RefObject) => ({ - onMouseEnter: () => iconRef.current?.startAnimation(), - onMouseLeave: () => iconRef.current?.stopAnimation(), - onFocus: () => iconRef.current?.startAnimation(), - onBlur: () => iconRef.current?.stopAnimation(), - }); - return (
-
- - - - - -

Home

-
-
- - - - - - -

History

-
-
- - - - - - -

Settings

-
-
- - - - - - -

Debug Logs

-
-
- - - - - - - - - -

Tools

-
-
- - onPageChange("audio-analysis")} className="gap-3 cursor-pointer py-2 px-3" {...getAnimatedItemHandlers(analyzerIconRef)}> - - Audio Quality Analyzer - - onPageChange("audio-resampler")} className="gap-3 cursor-pointer py-2 px-3" {...getAnimatedItemHandlers(resamplerIconRef)}> - - Audio Resampler - - onPageChange("audio-converter")} className="gap-3 cursor-pointer py-2 px-3" {...getAnimatedItemHandlers(converterIconRef)}> - - Audio Converter - - onPageChange("file-manager")} className="gap-3 cursor-pointer py-2 px-3" {...getAnimatedItemHandlers(fileManagerIconRef)}> - - File Manager - - -
-
- -
- - - - - - -

Report Bugs or Request Features

-
-
- - - Before Opening GitHub Issues - - - -
-
-

Important

-

- Search existing issues first and use the issue template when opening a new report or request. -

-
- - -
- - - - - -
-
- - - - - - -

Other Projects

-
-
- - - - - - -

Support Me

-
-
-
-
); -} diff --git a/frontend/src/components/SpectrumVisualization.tsx b/frontend/src/components/SpectrumVisualization.tsx deleted file mode 100644 index 6b9d288..0000000 --- a/frontend/src/components/SpectrumVisualization.tsx +++ /dev/null @@ -1,602 +0,0 @@ -import { useEffect, useRef, useState, forwardRef, useImperativeHandle } from "react"; -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; -} -type ColorScheme = AnalyzerColorScheme; -type FreqScale = AnalyzerFreqScale; -type WindowFunction = AnalyzerWindowFunction; -export interface SpectrogramRenderOptions { - spectrumData: SpectrumData; - sampleRate: number; - duration: number; - freqScale: FreqScale; - colorScheme: ColorScheme; - fileName?: string; - shouldCancel?: () => boolean; -} -interface SpectrumVisualizationProps { - sampleRate: number; - duration: number; - spectrumData?: SpectrumData; - fileName?: string; - onReAnalyze?: (fftSize: number, windowFunction: string) => void; - isAnalyzingSpectrum?: boolean; - spectrumProgress?: { - percent: number; - message: string; - }; -} -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); -} -export async function renderSpectrogramToCanvas(canvas: HTMLCanvasElement, options: SpectrogramRenderOptions): Promise { - canvas.width = CANVAS_W; - canvas.height = CANVAS_H; - const ctx = canvas.getContext("2d"); - if (!ctx) { - throw new Error("Cannot get 2D canvas context"); - } - await renderSpectrogram(ctx, options.spectrumData, options.sampleRate, options.duration, options.freqScale, options.colorScheme, options.fileName, options.shouldCancel ?? (() => false)); -} -export async function createSpectrogramDataURL(options: SpectrogramRenderOptions): Promise { - const canvas = document.createElement("canvas"); - await renderSpectrogramToCanvas(canvas, options); - return canvas.toDataURL("image/png"); -} -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(({ sampleRate, duration, spectrumData, fileName, onReAnalyze, isAnalyzingSpectrum, spectrumProgress, }, ref) => { - const canvasRef = useRef(null); - const preferencesRef = useRef(loadAudioAnalysisPreferences()); - useImperativeHandle(ref, () => ({ - getCanvasDataURL: () => { - if (!canvasRef.current) - return null; - return canvasRef.current.toDataURL("image/png"); - }, - })); - const [freqScale, setFreqScale] = useState(preferencesRef.current.freqScale); - const [colorScheme, setColorScheme] = useState(preferencesRef.current.colorScheme); - const [fftSize, setFftSize] = useState(() => String(preferencesRef.current.fftSize)); - const [windowFunction, setWindowFunction] = useState(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(() => { - const canvas = canvasRef.current; - if (!canvas) - return; - const ctx = canvas.getContext("2d"); - if (!ctx) - return; - let canceled = false; - const shouldCancel = () => canceled; - if (spectrumData) { - void renderSpectrogramToCanvas(canvas, { - spectrumData, - sampleRate, - duration, - freqScale, - colorScheme, - fileName, - shouldCancel, - }); - } - else { - ctx.fillStyle = "#000000"; - 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 spectrumPercent = Math.round(Math.max(0, Math.min(100, spectrumProgress?.percent ?? 0))); - return (
-
-
- - -
- -
- -
- - -
- -
- - -
- -
- - -
-
- -
- {isAnalyzingSpectrum && (
-
-
- Processing... - {spectrumPercent}% -
- -
-
)} - -
-
); -}); diff --git a/frontend/src/components/SupportPage.tsx b/frontend/src/components/SupportPage.tsx deleted file mode 100644 index 3811e9e..0000000 --- a/frontend/src/components/SupportPage.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import { useState } from "react"; -import { CircleCheck, Copy } from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { openExternal } from "@/lib/utils"; -import KofiLogo from "@/assets/ko-fi.gif"; -import KofiSvg from "@/assets/kofi_symbol.svg"; -import PatreonLogo from "@/assets/patreon.svg"; -import PatreonSymbol from "@/assets/patreon_symbol.svg"; -import UsdtBarcode from "@/assets/usdt.jpg"; - -export function SupportPage() { - const [copiedUsdt, setCopiedUsdt] = useState(false); - const [copiedEmail, setCopiedEmail] = useState(false); - return (
-
-

Support Me

-
- -
-
-
-
-
- Ko-fi -
-

Support via Ko-fi

-

- Buy me a coffee to help keep development going. -

-
- -
- -
-
-
- Patreon -
-

Support via Patreon

-

- Join on Patreon to help fund the project and follow updates. -

-
- -
- -
-
-
-
- USDT Barcode -
-
-

USDT (TRC20)

-

- Prefer crypto? Use the QR code or wallet address below. -

-
-
- - THnzAAwZgp2Sq5CAXLP2njQDhTvgZG9EWs - - -
-
-
- -
- If you have any questions or need help with donating, feel free to reach out via{" "} - {" "} - or{" "} - - . -
-
-
); -} diff --git a/frontend/src/components/TitleBar.tsx b/frontend/src/components/TitleBar.tsx deleted file mode 100644 index 992ebda..0000000 --- a/frontend/src/components/TitleBar.tsx +++ /dev/null @@ -1,197 +0,0 @@ -import { X, Minus, Maximize, SlidersHorizontal, Globe, Eye, EyeOff } from "lucide-react"; -import { WindowMinimise, WindowToggleMaximise, Quit } from "../../wailsjs/runtime/runtime"; -import { Menubar, MenubarContent, MenubarMenu, MenubarItem, MenubarTrigger, MenubarLabel, MenubarSeparator } from "@/components/ui/menubar"; -import { Slider } from "@/components/ui/slider"; -import { getSettings, updateSettings } from "@/lib/settings"; -import { PREVIEW_VOLUME_CHANGED_EVENT } from "@/lib/preview"; -import { fetchCurrentIPInfo } from "@/lib/api"; -import type { CurrentIPInfo } from "@/types/api"; -import { openExternal } from "@/lib/utils"; -import { useEffect, useRef, useState } from "react"; -const IP_INFO_REFRESH_INTERVAL_MS = 30000; -const SPOTIFY_BLOCKED_COUNTRY_CODES = new Set([ - "AF", - "IO", - "CF", - "CN", - "CU", - "ER", - "IR", - "MM", - "KP", - "RU", - "SO", - "SS", - "SD", - "SY", - "TM", - "YE", -]); -interface SettingsUpdatedDetail { - previewVolume?: number; -} -export function TitleBar() { - const initialSettings = getSettings(); - const [previewVolume, setPreviewVolume] = useState(initialSettings.previewVolume ?? 100); - const [currentIPInfo, setCurrentIPInfo] = useState(null); - const [isLoadingCurrentIPInfo, setIsLoadingCurrentIPInfo] = useState(false); - const [currentIPInfoError, setCurrentIPInfoError] = useState(""); - const [showIPAddress, setShowIPAddress] = useState(false); - const currentIPInfoRef = useRef(null); - useEffect(() => { - currentIPInfoRef.current = currentIPInfo; - }, [currentIPInfo]); - useEffect(() => { - const handleSettingsUpdate = (event: Event) => { - const updatedSettings = (event as CustomEvent).detail; - if (updatedSettings && typeof updatedSettings.previewVolume === "number") { - setPreviewVolume(updatedSettings.previewVolume); - } - }; - window.addEventListener("settingsUpdated", handleSettingsUpdate); - return () => window.removeEventListener("settingsUpdated", handleSettingsUpdate); - }, []); - const loadCurrentIPInfo = async (options?: { - silent?: boolean; - }) => { - const silent = options?.silent ?? false; - if (!silent) { - setIsLoadingCurrentIPInfo(true); - setCurrentIPInfoError(""); - } - try { - const info = await fetchCurrentIPInfo(); - setCurrentIPInfo(info); - setCurrentIPInfoError(""); - } - catch (error) { - if (!silent || !currentIPInfoRef.current) { - setCurrentIPInfo(null); - setCurrentIPInfoError(error instanceof Error ? error.message : "Unable to detect IP"); - } - } - finally { - if (!silent) { - setIsLoadingCurrentIPInfo(false); - } - } - }; - useEffect(() => { - void loadCurrentIPInfo(); - }, []); - useEffect(() => { - const intervalId = window.setInterval(() => { - void loadCurrentIPInfo({ silent: true }); - }, IP_INFO_REFRESH_INTERVAL_MS); - const handleFocus = () => { - if (document.visibilityState === "hidden") { - return; - } - void loadCurrentIPInfo({ silent: true }); - }; - window.addEventListener("focus", handleFocus); - document.addEventListener("visibilitychange", handleFocus); - return () => { - window.clearInterval(intervalId); - window.removeEventListener("focus", handleFocus); - document.removeEventListener("visibilitychange", handleFocus); - }; - }, []); - const handleMinimize = () => { - WindowMinimise(); - }; - const handleMaximize = () => { - WindowToggleMaximise(); - }; - const handleClose = () => { - Quit(); - }; - const handlePreviewVolumeChange = (value: number[]) => { - const nextValue = value[0]; - if (typeof nextValue !== "number" || Number.isNaN(nextValue)) { - return; - } - setPreviewVolume(nextValue); - window.dispatchEvent(new CustomEvent(PREVIEW_VOLUME_CHANGED_EVENT, { detail: nextValue })); - }; - const handlePreviewVolumeCommit = (value: number[]) => { - const nextValue = value[0]; - if (typeof nextValue !== "number" || Number.isNaN(nextValue)) { - return; - } - setPreviewVolume(nextValue); - void updateSettings({ previewVolume: nextValue }); - }; - const detectedCountryCode = currentIPInfo?.country_code?.toUpperCase() || ""; - const detectedFlagPath = detectedCountryCode ? `/assets/flags/${detectedCountryCode.toLowerCase()}.svg` : ""; - const isSpotifyBlockedCountry = detectedCountryCode !== "" && SPOTIFY_BLOCKED_COUNTRY_CODES.has(detectedCountryCode); - return (<> - -
- - -
- - - - - - -
-
- Preview Volume - - {previewVolume}% - -
- -
- -
- Network - {isSpotifyBlockedCountry && ( - (Blocked by Spotify) - )} -
-
-
-
- {detectedFlagPath ? ({detectedCountryCode}) : ()} - - {isLoadingCurrentIPInfo - ? "Detecting..." - : currentIPInfo - ? showIPAddress - ? `${currentIPInfo.ip} - ${currentIPInfo.country}${detectedCountryCode ? ` (${detectedCountryCode})` : ""}` - : `${currentIPInfo.country}${detectedCountryCode ? ` (${detectedCountryCode})` : ""}` - : "Unavailable"} - -
- {currentIPInfo && !isLoadingCurrentIPInfo && ()} -
- {!isLoadingCurrentIPInfo && !currentIPInfo && currentIPInfoError && (
- IP detection unavailable -
)} -
- - openExternal("https://afkarxyz.fyi")} className="gap-2"> - - Website - -
-
-
- - - -
- ); -} diff --git a/frontend/src/components/TrackInfo.tsx b/frontend/src/components/TrackInfo.tsx deleted file mode 100644 index 4ea8e7a..0000000 --- a/frontend/src/components/TrackInfo.tsx +++ /dev/null @@ -1,190 +0,0 @@ -import { Button } from "@/components/ui/button"; -import { Card, CardContent } from "@/components/ui/card"; -import { Download, FolderOpen, CheckCircle, XCircle, FileText, FileCheck, Globe, ImageDown, Play, Pause } from "lucide-react"; -import { Spinner } from "@/components/ui/spinner"; -import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; -import type { TrackMetadata, TrackAvailability } from "@/types/api"; -import { usePreview } from "@/hooks/usePreview"; -import { AvailabilityLinks, hasAvailabilityLinks } from "./AvailabilityLinks"; -import { buildClickableArtists } from "@/lib/artist-links"; -interface TrackInfoProps { - track: TrackMetadata & { - album_name: string; - release_date: string; - }; - isDownloading: boolean; - downloadingTrack: string | null; - isDownloaded: boolean; - isFailed: boolean; - isSkipped: boolean; - downloadingLyricsTrack?: string | null; - downloadedLyrics?: boolean; - failedLyrics?: boolean; - skippedLyrics?: boolean; - checkingAvailability?: boolean; - availability?: TrackAvailability; - downloadingCover?: boolean; - downloadedCover?: boolean; - failedCover?: boolean; - skippedCover?: boolean; - 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; - 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; - onOpenFolder: () => void; - onAlbumClick?: (album: { - id: string; - name: string; - external_urls: string; - }) => void; - onArtistClick?: (artist: { - id: string; - name: string; - external_urls: string; - }) => void; - onBack?: () => void; -} -export function TrackInfo({ track, isDownloading, downloadingTrack, isDownloaded, isFailed, isSkipped, downloadingLyricsTrack, downloadedLyrics, failedLyrics, skippedLyrics, checkingAvailability, availability, downloadingCover, downloadedCover, failedCover, skippedCover, onDownload, onDownloadLyrics, onCheckAvailability, onDownloadCover, onOpenFolder, onAlbumClick, onArtistClick, onBack, }: TrackInfoProps) { - const { playPreview, loadingPreview, playingTrack } = usePreview(); - const hasAlbumClick = !!(onAlbumClick && track.album_id && track.album_url); - const clickableArtists = buildClickableArtists(track.artists, track.artists_data, track.artist_id, track.artist_url); - const formatDuration = (ms: number) => { - const minutes = Math.floor(ms / 60000); - const seconds = Math.floor((ms % 60000) / 1000); - return `${minutes}:${seconds.toString().padStart(2, "0")}`; - }; - const formatPlays = (plays: string) => { - const num = parseInt(plays, 10); - if (isNaN(num)) - return plays; - return num.toLocaleString(); - }; - return ( - {onBack && (
- -
)} - -
-
- {track.images && (
- {track.name} -
- {formatDuration(track.duration_ms)} -
-
)} -
-
-
-
-

{track.name}

- {track.is_explicit && (E)} - {isSkipped ? () : isDownloaded ? () : isFailed ? () : null} -
-

- {clickableArtists.length > 0 ? clickableArtists.map((artist, index) => ( - {onArtistClick ? ( onArtistClick({ - id: artist.id, - name: artist.name, - external_urls: artist.external_urls, - })}> - {artist.name} - ) : (artist.name)} - {index < clickableArtists.length - 1 && ", "} - )) : track.artists} -

-
-
-
-
-

Album

-

{hasAlbumClick ? ( onAlbumClick?.({ - id: track.album_id!, - name: track.album_name, - external_urls: track.album_url!, - })}> - {track.album_name} - ) : (track.album_name)}

-
- {track.plays && (
-

Total Plays

-

{formatPlays(track.plays)}

-
)} -
-
-
-

Release Date

-

{track.release_date}

-
- {track.copyright && (
-

Copyright

-

- {track.copyright} -

-
)} -
-
- {track.spotify_id && (
- - {track.spotify_id && ( - - - - -

{playingTrack === track.spotify_id ? "Stop Preview" : "Play Preview"}

-
-
)} - {track.spotify_id && onDownloadLyrics && ( - - - - -

Download Separate Lyric

-
-
)} - {track.images && onDownloadCover && ( - - - - -

Download Separate Cover

-
-
)} - {track.spotify_id && onCheckAvailability && ( - - - - - - - )} - {isDownloaded && ( - - - - -

Open Folder

-
-
)} -
)} -
-
-
-
); -} diff --git a/frontend/src/components/TrackList.tsx b/frontend/src/components/TrackList.tsx deleted file mode 100644 index c161814..0000000 --- a/frontend/src/components/TrackList.tsx +++ /dev/null @@ -1,384 +0,0 @@ -import { Button } from "@/components/ui/button"; -import { Checkbox } from "@/components/ui/checkbox"; -import { Download, CheckCircle, XCircle, FileCheck, FileText, Globe, ImageDown, Play, Pause } from "lucide-react"; -import { Spinner } from "@/components/ui/spinner"; -import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; -import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious, } from "@/components/ui/pagination"; -import type { TrackMetadata, TrackAvailability } from "@/types/api"; -import { usePreview } from "@/hooks/usePreview"; -import { AvailabilityLinks, hasAvailabilityLinks } from "./AvailabilityLinks"; -import { buildClickableArtists } from "@/lib/artist-links"; -interface TrackListProps { - tracks: TrackMetadata[]; - searchQuery: string; - sortBy: string; - selectedTracks: string[]; - downloadedTracks: Set; - failedTracks: Set; - skippedTracks: Set; - downloadingTrack: string | null; - isDownloading: boolean; - currentPage: number; - itemsPerPage: number; - showCheckboxes?: boolean; - hideAlbumColumn?: boolean; - folderName?: string; - isArtistDiscography?: boolean; - downloadedLyrics?: Set; - failedLyrics?: Set; - skippedLyrics?: Set; - downloadingLyricsTrack?: string | null; - checkingAvailabilityTrack?: string | null; - availabilityMap?: Map; - downloadedCovers?: Set; - failedCovers?: Set; - skippedCovers?: Set; - downloadingCoverTrack?: string | null; - onToggleTrack: (id: string) => void; - onToggleSelectAll: (tracks: TrackMetadata[]) => 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; - 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; - onPageChange: (page: number) => void; - onAlbumClick?: (album: { - id: string; - name: string; - external_urls: string; - }) => void; - onArtistClick?: (artist: { - id: string; - name: string; - external_urls: string; - }) => void; - onTrackClick?: (track: TrackMetadata) => void; -} -export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, currentPage, itemsPerPage, showCheckboxes = false, hideAlbumColumn = false, folderName, isArtistDiscography = false, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onCheckAvailability, onDownloadCover, onPageChange, onAlbumClick, onArtistClick, onTrackClick, }: TrackListProps) { - const { playPreview, loadingPreview, playingTrack } = usePreview(); - let filteredTracks = tracks.filter((track) => { - if (!searchQuery) - return true; - const query = searchQuery.toLowerCase(); - return (track.name.toLowerCase().includes(query) || - track.artists.toLowerCase().includes(query) || - track.album_name.toLowerCase().includes(query)); - }); - if (sortBy === "title-asc") { - filteredTracks = [...filteredTracks].sort((a, b) => a.name.localeCompare(b.name)); - } - else if (sortBy === "title-desc") { - filteredTracks = [...filteredTracks].sort((a, b) => b.name.localeCompare(a.name)); - } - else if (sortBy === "artist-asc") { - filteredTracks = [...filteredTracks].sort((a, b) => a.artists.localeCompare(b.artists)); - } - else if (sortBy === "artist-desc") { - filteredTracks = [...filteredTracks].sort((a, b) => b.artists.localeCompare(a.artists)); - } - else if (sortBy === "duration-asc") { - filteredTracks = [...filteredTracks].sort((a, b) => a.duration_ms - b.duration_ms); - } - else if (sortBy === "duration-desc") { - filteredTracks = [...filteredTracks].sort((a, b) => b.duration_ms - a.duration_ms); - } - else if (sortBy === "plays-asc") { - filteredTracks = [...filteredTracks].sort((a, b) => { - const aPlays = a.plays ? parseInt(a.plays, 10) : 0; - const bPlays = b.plays ? parseInt(b.plays, 10) : 0; - if (isNaN(aPlays)) - return 1; - if (isNaN(bPlays)) - return -1; - return aPlays - bPlays; - }); - } - else if (sortBy === "plays-desc") { - filteredTracks = [...filteredTracks].sort((a, b) => { - const aPlays = a.plays ? parseInt(a.plays, 10) : 0; - const bPlays = b.plays ? parseInt(b.plays, 10) : 0; - if (isNaN(aPlays)) - return 1; - if (isNaN(bPlays)) - return -1; - return bPlays - aPlays; - }); - } - else if (sortBy === "downloaded") { - filteredTracks = [...filteredTracks].sort((a, b) => { - const aDownloaded = a.spotify_id ? downloadedTracks.has(a.spotify_id) : false; - const bDownloaded = b.spotify_id ? downloadedTracks.has(b.spotify_id) : false; - return (bDownloaded ? 1 : 0) - (aDownloaded ? 1 : 0); - }); - } - else if (sortBy === "not-downloaded") { - filteredTracks = [...filteredTracks].sort((a, b) => { - const aDownloaded = a.spotify_id ? downloadedTracks.has(a.spotify_id) : false; - const bDownloaded = b.spotify_id ? downloadedTracks.has(b.spotify_id) : false; - 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 startIndex = (currentPage - 1) * itemsPerPage; - const endIndex = startIndex + itemsPerPage; - const paginatedTracks = filteredTracks.slice(startIndex, endIndex); - const getPaginationPages = (current: number, total: number): (number | 'ellipsis')[] => { - if (total <= 10) { - return Array.from({ length: total }, (_, i) => i + 1); - } - const pages: (number | 'ellipsis')[] = []; - pages.push(1); - if (current <= 7) { - for (let i = 2; i <= 10; i++) { - pages.push(i); - } - pages.push('ellipsis'); - pages.push(total); - } - else if (current >= total - 7) { - pages.push('ellipsis'); - for (let i = total - 9; i <= total; i++) { - pages.push(i); - } - } - else { - pages.push('ellipsis'); - pages.push(current - 1); - pages.push(current); - pages.push(current + 1); - pages.push('ellipsis'); - pages.push(total); - } - return pages; - }; - const tracksWithId = filteredTracks.filter((track) => track.spotify_id); - const allSelected = tracksWithId.length > 0 && - tracksWithId.every((track) => selectedTracks.includes(track.spotify_id!)); - const formatDuration = (ms: number) => { - const minutes = Math.floor(ms / 60000); - const seconds = Math.floor((ms % 60000) / 1000); - return `${minutes}:${seconds.toString().padStart(2, "0")}`; - }; - const formatPlays = (plays: string | undefined) => { - if (!plays) - return ""; - const num = parseInt(plays, 10); - if (isNaN(num)) - return plays; - return num.toLocaleString(); - }; - const getAvailabilityButtonIcon = (spotifyId?: string) => { - if (!spotifyId) { - return ; - } - if (checkingAvailabilityTrack === spotifyId) { - return ; - } - const availability = availabilityMap?.get(spotifyId); - if (!availability) { - return ; - } - if (hasAvailabilityLinks(availability)) { - return ; - } - return ; - }; - return (
-
-
- - - - {showCheckboxes && ()} - - - {!hideAlbumColumn && ()} - - - - - - - {paginatedTracks.map((track, index) => ( - {showCheckboxes && ()} - - - {!hideAlbumColumn && ()} - - - - ))} - -
- onToggleSelectAll(filteredTracks)}/> - - # - - Title - - Album - - Duration - - Plays - - Actions -
- {track.spotify_id && ( onToggleTrack(track.spotify_id!)}/>)} - -
- {startIndex + index + 1} - {track.status && (track.status === "UP" || track.status === "DOWN" || track.status === "NEW") && ( - {track.status === "NEW" ? "●" : track.status === "UP" ? "▲" : "▼"} - )} -
-
-
- {track.images && ({track.name})} -
-
- {onTrackClick ? ( onTrackClick(track)}> - {track.name} - ) : ({track.name})} - {track.is_explicit && (E)} - - {track.spotify_id && skippedTracks.has(track.spotify_id) ? () : track.spotify_id && downloadedTracks.has(track.spotify_id) ? () : track.spotify_id && failedTracks.has(track.spotify_id) ? () : null} -
- - {(() => { - const clickableArtists = buildClickableArtists(track.artists, track.artists_data, track.artist_id, track.artist_url); - if (clickableArtists.length === 0) { - return track.artists; - } - return clickableArtists.map((artist, i) => ( - {onArtistClick ? ( onArtistClick({ - id: artist.id, - name: artist.name, - external_urls: artist.external_urls, - })}> - {artist.name} - ) : (artist.name)} - {i < clickableArtists.length - 1 && ", "} - )); - })()} - -
-
-
- {onAlbumClick && track.album_id && track.album_url ? ( onAlbumClick({ - id: track.album_id!, - name: track.album_name, - external_urls: track.album_url!, - })}> - {track.album_name} - ) : (track.album_name)} - - {formatDuration(track.duration_ms)} - - {track.plays ? formatPlays(track.plays) : ""} - -
- {track.spotify_id && ( - - - - - {downloadingTrack === track.spotify_id ? (

Downloading...

) : skippedTracks.has(track.spotify_id) ? (

Already exists

) : downloadedTracks.has(track.spotify_id) ? (

Downloaded

) : failedTracks.has(track.spotify_id) ? (

Failed

) : (

Download Track

)} -
-
)} - {track.spotify_id && ( - - - - -

{playingTrack === track.spotify_id ? "Stop Preview" : "Play Preview"}

-
-
)} - {track.spotify_id && onDownloadLyrics && ( - - - - -

Download Separate Lyric

-
-
)} - {track.images && onDownloadCover && ( - - - - -

Download Separate Cover

-
-
)} - {track.spotify_id && onCheckAvailability && ( - - - - - - - )} -
-
-
-
- - {totalPages > 1 && ( - - - { - e.preventDefault(); - if (currentPage > 1) - onPageChange(currentPage - 1); - }} className={currentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"}/> - - - {getPaginationPages(currentPage, totalPages).map((page, index) => (page === 'ellipsis' ? ( - - ) : ( - { - e.preventDefault(); - onPageChange(page); - }} isActive={currentPage === page} className="cursor-pointer"> - {page} - - )))} - - - { - e.preventDefault(); - if (currentPage < totalPages) - onPageChange(currentPage + 1); - }} className={currentPage === totalPages - ? "pointer-events-none opacity-50" - : "cursor-pointer"}/> - - - )} -
); -} diff --git a/frontend/src/components/ui/activity.tsx b/frontend/src/components/ui/activity.tsx deleted file mode 100644 index 1cde669..0000000 --- a/frontend/src/components/ui/activity.tsx +++ /dev/null @@ -1,63 +0,0 @@ -'use client'; -import type { Variants } from 'motion/react'; -import type { HTMLAttributes } from 'react'; -import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react'; -import { motion, useAnimation } from 'motion/react'; -import { cn } from '@/lib/utils'; -export interface ActivityIconHandle { - startAnimation: () => void; - stopAnimation: () => void; -} -interface ActivityIconProps extends HTMLAttributes { - size?: number; -} -const PATH_VARIANTS: Variants = { - normal: { - pathLength: 1, - opacity: 1, - pathOffset: 0, - }, - animate: { - pathLength: [0, 1], - opacity: [0, 1], - pathOffset: [1, 0], - transition: { - duration: 0.8, - ease: 'easeInOut', - }, - }, -}; -const ActivityIcon = forwardRef(({ 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) => { - if (!isControlledRef.current) { - controls.start('animate'); - } - else { - onMouseEnter?.(e); - } - }, [controls, onMouseEnter]); - const handleMouseLeave = useCallback((e: React.MouseEvent) => { - if (!isControlledRef.current) { - controls.start('normal'); - } - else { - onMouseLeave?.(e); - } - }, [controls, onMouseLeave]); - return (
- - - -
); -}); -ActivityIcon.displayName = 'ActivityIcon'; -export { ActivityIcon }; diff --git a/frontend/src/components/ui/audio-lines.tsx b/frontend/src/components/ui/audio-lines.tsx deleted file mode 100644 index 0040347..0000000 --- a/frontend/src/components/ui/audio-lines.tsx +++ /dev/null @@ -1,87 +0,0 @@ -"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 { - size?: number; -} -const AudioLinesIcon = forwardRef(({ 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) => { - if (isControlledRef.current) { - onMouseEnter?.(e); - } - else { - controls.start("animate"); - } - }, [controls, onMouseEnter]); - const handleMouseLeave = useCallback((e: React.MouseEvent) => { - if (isControlledRef.current) { - onMouseLeave?.(e); - } - else { - controls.start("normal"); - } - }, [controls, onMouseLeave]); - return (
- - - - - - - - -
); -}); -AudioLinesIcon.displayName = "AudioLinesIcon"; -export { AudioLinesIcon }; diff --git a/frontend/src/components/ui/badge.tsx b/frontend/src/components/ui/badge.tsx deleted file mode 100644 index 569cbdd..0000000 --- a/frontend/src/components/ui/badge.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import * as React from "react"; -import { Slot } from "@radix-ui/react-slot"; -import { cva, type VariantProps } from "class-variance-authority"; -import { cn } from "@/lib/utils"; -const badgeVariants = cva("inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", { - variants: { - variant: { - default: "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", - secondary: "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", - destructive: "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", - outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", - }, - }, - defaultVariants: { - variant: "default", - }, -}); -function Badge({ className, variant, asChild = false, ...props }: React.ComponentProps<"span"> & VariantProps & { - asChild?: boolean; -}) { - const Comp = asChild ? Slot : "span"; - return (); -} -export { Badge, badgeVariants }; diff --git a/frontend/src/components/ui/blocks-icon.tsx b/frontend/src/components/ui/blocks-icon.tsx deleted file mode 100644 index b7d7029..0000000 --- a/frontend/src/components/ui/blocks-icon.tsx +++ /dev/null @@ -1,53 +0,0 @@ -"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 { - size?: number; - loop?: boolean; -} -const VARIANTS: Variants = { - normal: { translateX: 0, translateY: 0 }, - animate: { translateX: -4, translateY: 4 }, -}; -const BlocksIcon = forwardRef(({ 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) => { - if (isControlledRef.current) { - onMouseEnter?.(e); - } - else { - controls.start("animate"); - } - }, [controls, onMouseEnter]); - const handleMouseLeave = useCallback((e: React.MouseEvent) => { - if (isControlledRef.current) { - onMouseLeave?.(e); - } - else { - controls.start("normal"); - } - }, [controls, onMouseLeave]); - return (
- - - - -
); -}); -BlocksIcon.displayName = "BlocksIcon"; -export { BlocksIcon }; diff --git a/frontend/src/components/ui/bug-report-icon.tsx b/frontend/src/components/ui/bug-report-icon.tsx deleted file mode 100644 index 463f9ff..0000000 --- a/frontend/src/components/ui/bug-report-icon.tsx +++ /dev/null @@ -1,132 +0,0 @@ -"use client"; - -import type { Transition, Variants } from "motion/react"; -import { AnimatePresence, motion } from "motion/react"; -import { useEffect, useState, type HTMLAttributes } from "react"; -import { cn } from "@/lib/utils"; - -type ReportIconMode = "bug" | "bulb"; - -interface BugReportIconProps extends HTMLAttributes { - size?: number; - loop?: boolean; -} - -const LOOP_INTERVAL_MS = 2200; - -const GROUP_VARIANTS: Variants = { - hidden: { - opacity: 0, - }, - visible: { - opacity: 1, - transition: { - duration: 0.2, - ease: [0, 0, 0.2, 1], - }, - }, - exit: { - opacity: 0, - transition: { - duration: 0.18, - ease: [0.4, 0, 1, 1], - }, - }, -}; - -const DRAW_VARIANTS: Variants = { - hidden: { - pathLength: 0, - opacity: 0, - }, - visible: { - pathLength: 1, - opacity: 1, - }, - exit: { - pathLength: 1, - opacity: 0, - }, -}; - -function createDrawTransition(delay = 0, duration = 0.36): Transition { - return { - duration, - delay, - ease: [0.4, 0, 0.2, 1], - opacity: { delay }, - }; -} - -function BugPaths() { - return (<> - - - - - - - - - - - - ); -} - -function BulbPaths() { - return (<> - - - - ); -} - -function ReportIconGroup({ mode }: { mode: ReportIconMode }) { - return ( - {mode === "bug" ? : } - ); -} - -function StaticBugIcon() { - return ( - - - - - - - - - - - - ); -} - -function BugReportIcon({ className, size = 28, loop = false, ...props }: BugReportIconProps) { - const [mode, setMode] = useState("bug"); - - useEffect(() => { - if (!loop) { - setMode("bug"); - return; - } - - const intervalId = window.setInterval(() => { - setMode((currentMode) => currentMode === "bug" ? "bulb" : "bug"); - }, LOOP_INTERVAL_MS); - - return () => window.clearInterval(intervalId); - }, [loop]); - - return (
- - {loop ? ( - - ) : ()} - -
); -} - -export { BugReportIcon }; diff --git a/frontend/src/components/ui/button.tsx b/frontend/src/components/ui/button.tsx deleted file mode 100644 index a6bb15e..0000000 --- a/frontend/src/components/ui/button.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import * as React from "react"; -import { Slot } from "@radix-ui/react-slot"; -import { cva, type VariantProps } from "class-variance-authority"; -import { cn } from "@/lib/utils"; -const buttonVariants = cva("inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all cursor-pointer disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", { - variants: { - variant: { - default: "bg-primary text-primary-foreground hover:bg-primary/90", - destructive: "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", - outline: "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", - secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", - ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", - link: "text-primary underline-offset-4 hover:underline", - }, - size: { - default: "h-9 px-4 py-2 has-[>svg]:px-3", - sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", - lg: "h-10 rounded-md px-6 has-[>svg]:px-4", - icon: "h-9 w-9 p-0", - "icon-sm": "h-8 w-8 p-0", - "icon-lg": "h-10 w-10 p-0", - }, - }, - defaultVariants: { - variant: "default", - size: "default", - }, -}); -function Button({ className, variant, size, asChild = false, ...props }: React.ComponentProps<"button"> & VariantProps & { - asChild?: boolean; -}) { - const Comp = asChild ? Slot : "button"; - return (); -} -export { Button, buttonVariants }; diff --git a/frontend/src/components/ui/card.tsx b/frontend/src/components/ui/card.tsx deleted file mode 100644 index 9beb361..0000000 --- a/frontend/src/components/ui/card.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import * as React from "react"; -import { cn } from "@/lib/utils"; -function Card({ className, ...props }: React.ComponentProps<"div">) { - return (
); -} -function CardHeader({ className, ...props }: React.ComponentProps<"div">) { - return (
); -} -function CardTitle({ className, ...props }: React.ComponentProps<"div">) { - return (
); -} -function CardDescription({ className, ...props }: React.ComponentProps<"div">) { - return (
); -} -function CardAction({ className, ...props }: React.ComponentProps<"div">) { - return (
); -} -function CardContent({ className, ...props }: React.ComponentProps<"div">) { - return (
); -} -function CardFooter({ className, ...props }: React.ComponentProps<"div">) { - return (
); -} -export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent, }; diff --git a/frontend/src/components/ui/checkbox.tsx b/frontend/src/components/ui/checkbox.tsx deleted file mode 100644 index 4da2c71..0000000 --- a/frontend/src/components/ui/checkbox.tsx +++ /dev/null @@ -1,13 +0,0 @@ -"use client"; -import * as React from "react"; -import * as CheckboxPrimitive from "@radix-ui/react-checkbox"; -import { CheckIcon } from "lucide-react"; -import { cn } from "@/lib/utils"; -function Checkbox({ className, ...props }: React.ComponentProps) { - return ( - - - - ); -} -export { Checkbox }; diff --git a/frontend/src/components/ui/coffee.tsx b/frontend/src/components/ui/coffee.tsx deleted file mode 100644 index 0dd8da8..0000000 --- a/frontend/src/components/ui/coffee.tsx +++ /dev/null @@ -1,67 +0,0 @@ -'use client'; -import type { Variants } from 'motion/react'; -import type { HTMLAttributes } from 'react'; -import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react'; -import { motion, useAnimation } from 'motion/react'; -import { cn } from '@/lib/utils'; -export interface CoffeeIconHandle { - startAnimation: () => void; - stopAnimation: () => void; -} -interface CoffeeIconProps extends HTMLAttributes { - size?: number; - loop?: boolean; -} -const PATH_VARIANTS: Variants = { - normal: { - y: 0, - opacity: 1, - }, - animate: (custom: number) => ({ - y: -3, - opacity: [0, 1, 0], - transition: { - repeat: Infinity, - duration: 1.5, - ease: 'easeInOut', - delay: 0.2 * custom, - }, - }), -}; -const CoffeeIcon = forwardRef(({ 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) => { - if (!isControlledRef.current) { - controls.start('animate'); - } - else { - onMouseEnter?.(e); - } - }, [controls, onMouseEnter]); - const handleMouseLeave = useCallback((e: React.MouseEvent) => { - if (!isControlledRef.current) { - controls.start('normal'); - } - else { - onMouseLeave?.(e); - } - }, [controls, onMouseLeave]); - return (
- - - - - - -
); -}); -CoffeeIcon.displayName = 'CoffeeIcon'; -export { CoffeeIcon }; diff --git a/frontend/src/components/ui/context-menu.tsx b/frontend/src/components/ui/context-menu.tsx deleted file mode 100644 index 5a334e3..0000000 --- a/frontend/src/components/ui/context-menu.tsx +++ /dev/null @@ -1,77 +0,0 @@ -"use client"; -import * as React from "react"; -import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"; -import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"; -import { cn } from "@/lib/utils"; -function ContextMenu({ ...props }: React.ComponentProps) { - return ; -} -function ContextMenuTrigger({ ...props }: React.ComponentProps) { - return (); -} -function ContextMenuGroup({ ...props }: React.ComponentProps) { - return (); -} -function ContextMenuPortal({ ...props }: React.ComponentProps) { - return (); -} -function ContextMenuSub({ ...props }: React.ComponentProps) { - return ; -} -function ContextMenuRadioGroup({ ...props }: React.ComponentProps) { - return (); -} -function ContextMenuSubTrigger({ className, inset, children, ...props }: React.ComponentProps & { - inset?: boolean; -}) { - return ( - {children} - - ); -} -function ContextMenuSubContent({ className, ...props }: React.ComponentProps) { - return (); -} -function ContextMenuContent({ className, ...props }: React.ComponentProps) { - return ( - - ); -} -function ContextMenuItem({ className, inset, variant = "default", ...props }: React.ComponentProps & { - inset?: boolean; - variant?: "default" | "destructive"; -}) { - return (); -} -function ContextMenuCheckboxItem({ className, children, checked, ...props }: React.ComponentProps) { - return ( - - - - - - {children} - ); -} -function ContextMenuRadioItem({ className, children, ...props }: React.ComponentProps) { - return ( - - - - - - {children} - ); -} -function ContextMenuLabel({ className, inset, ...props }: React.ComponentProps & { - inset?: boolean; -}) { - return (); -} -function ContextMenuSeparator({ className, ...props }: React.ComponentProps) { - return (); -} -function ContextMenuShortcut({ className, ...props }: React.ComponentProps<"span">) { - return (); -} -export { ContextMenu, ContextMenuTrigger, ContextMenuContent, ContextMenuItem, ContextMenuCheckboxItem, ContextMenuRadioItem, ContextMenuLabel, ContextMenuSeparator, ContextMenuShortcut, ContextMenuGroup, ContextMenuPortal, ContextMenuSub, ContextMenuSubContent, ContextMenuSubTrigger, ContextMenuRadioGroup, }; diff --git a/frontend/src/components/ui/dialog.tsx b/frontend/src/components/ui/dialog.tsx deleted file mode 100644 index 8c5ef4f..0000000 --- a/frontend/src/components/ui/dialog.tsx +++ /dev/null @@ -1,47 +0,0 @@ -"use client"; -import * as React from "react"; -import * as DialogPrimitive from "@radix-ui/react-dialog"; -import { XIcon } from "lucide-react"; -import { cn } from "@/lib/utils"; -function Dialog({ ...props }: React.ComponentProps) { - return ; -} -function DialogTrigger({ ...props }: React.ComponentProps) { - return ; -} -function DialogPortal({ ...props }: React.ComponentProps) { - return ; -} -function DialogClose({ ...props }: React.ComponentProps) { - return ; -} -function DialogOverlay({ className, ...props }: React.ComponentProps) { - return (); -} -function DialogContent({ className, children, showCloseButton = true, ...props }: React.ComponentProps & { - showCloseButton?: boolean; -}) { - return ( - - - {children} - {showCloseButton && ( - - Close - )} - - ); -} -function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { - return (
); -} -function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { - return (
); -} -function DialogTitle({ className, ...props }: React.ComponentProps) { - return (); -} -function DialogDescription({ className, ...props }: React.ComponentProps) { - return (); -} -export { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger, }; diff --git a/frontend/src/components/ui/dropdown-menu.tsx b/frontend/src/components/ui/dropdown-menu.tsx deleted file mode 100644 index c769f7e..0000000 --- a/frontend/src/components/ui/dropdown-menu.tsx +++ /dev/null @@ -1,76 +0,0 @@ -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) { - return ; -} -function DropdownMenuPortal({ ...props }: React.ComponentProps) { - return (); -} -function DropdownMenuTrigger({ ...props }: React.ComponentProps) { - return (); -} -function DropdownMenuContent({ className, sideOffset = 4, ...props }: React.ComponentProps) { - return ( - - ); -} -function DropdownMenuGroup({ ...props }: React.ComponentProps) { - return (); -} -function DropdownMenuItem({ className, inset, variant = "default", ...props }: React.ComponentProps & { - inset?: boolean; - variant?: "default" | "destructive"; -}) { - return (); -} -function DropdownMenuCheckboxItem({ className, children, checked, ...props }: React.ComponentProps) { - return ( - - - - - - {children} - ); -} -function DropdownMenuRadioGroup({ ...props }: React.ComponentProps) { - return (); -} -function DropdownMenuRadioItem({ className, children, ...props }: React.ComponentProps) { - return ( - - - - - - {children} - ); -} -function DropdownMenuLabel({ className, inset, ...props }: React.ComponentProps & { - inset?: boolean; -}) { - return (); -} -function DropdownMenuSeparator({ className, ...props }: React.ComponentProps) { - return (); -} -function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<"span">) { - return (); -} -function DropdownMenuSub({ ...props }: React.ComponentProps) { - return ; -} -function DropdownMenuSubTrigger({ className, inset, children, ...props }: React.ComponentProps & { - inset?: boolean; -}) { - return ( - {children} - - ); -} -function DropdownMenuSubContent({ className, ...props }: React.ComponentProps) { - return (); -} -export { DropdownMenu, DropdownMenuPortal, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuGroup, DropdownMenuLabel, DropdownMenuItem, DropdownMenuCheckboxItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent, }; diff --git a/frontend/src/components/ui/file-music.tsx b/frontend/src/components/ui/file-music.tsx deleted file mode 100644 index aa0d576..0000000 --- a/frontend/src/components/ui/file-music.tsx +++ /dev/null @@ -1,64 +0,0 @@ -'use client'; -import type { Variants } from 'motion/react'; -import type { HTMLAttributes } from 'react'; -import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react'; -import { motion, useAnimation } from 'motion/react'; -import { cn } from '@/lib/utils'; -export interface FileMusicIconHandle { - startAnimation: () => void; - stopAnimation: () => void; -} -interface FileMusicIconProps extends HTMLAttributes { - size?: number; -} -const PATH_VARIANTS: Variants = { - normal: { - pathLength: 1, - opacity: 1, - }, - animate: { - pathLength: [0, 1], - opacity: [0, 1], - transition: { - duration: 0.6, - ease: 'easeInOut', - }, - }, -}; -const FileMusicIcon = forwardRef(({ 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) => { - if (!isControlledRef.current) { - controls.start('animate'); - } - else { - onMouseEnter?.(e); - } - }, [controls, onMouseEnter]); - const handleMouseLeave = useCallback((e: React.MouseEvent) => { - if (!isControlledRef.current) { - controls.start('normal'); - } - else { - onMouseLeave?.(e); - } - }, [controls, onMouseLeave]); - return (
- - - - - - -
); -}); -FileMusicIcon.displayName = 'FileMusicIcon'; -export { FileMusicIcon }; diff --git a/frontend/src/components/ui/file-pen.tsx b/frontend/src/components/ui/file-pen.tsx deleted file mode 100644 index ccc727f..0000000 --- a/frontend/src/components/ui/file-pen.tsx +++ /dev/null @@ -1,63 +0,0 @@ -'use client'; -import type { Variants } from 'motion/react'; -import type { HTMLAttributes } from 'react'; -import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react'; -import { motion, useAnimation } from 'motion/react'; -import { cn } from '@/lib/utils'; -export interface FilePenIconHandle { - startAnimation: () => void; - stopAnimation: () => void; -} -interface FilePenIconProps extends HTMLAttributes { - size?: number; -} -const PATH_VARIANTS: Variants = { - normal: { - pathLength: 1, - opacity: 1, - }, - animate: { - pathLength: [0, 1], - opacity: [0, 1], - transition: { - duration: 0.6, - ease: 'easeInOut', - }, - }, -}; -const FilePenIcon = forwardRef(({ 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) => { - if (!isControlledRef.current) { - controls.start('animate'); - } - else { - onMouseEnter?.(e); - } - }, [controls, onMouseEnter]); - const handleMouseLeave = useCallback((e: React.MouseEvent) => { - if (!isControlledRef.current) { - controls.start('normal'); - } - else { - onMouseLeave?.(e); - } - }, [controls, onMouseLeave]); - return (
- - - - - -
); -}); -FilePenIcon.displayName = 'FilePenIcon'; -export { FilePenIcon }; diff --git a/frontend/src/components/ui/history-icon.tsx b/frontend/src/components/ui/history-icon.tsx deleted file mode 100644 index a954d37..0000000 --- a/frontend/src/components/ui/history-icon.tsx +++ /dev/null @@ -1,97 +0,0 @@ -"use client"; -import type { Transition, 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 HistoryIconHandle { - startAnimation: () => void; - stopAnimation: () => void; -} -interface HistoryIconProps extends HTMLAttributes { - size?: number; -} -const ARROW_TRANSITION: Transition = { - type: "spring", - stiffness: 250, - damping: 25, -}; -const ARROW_VARIANTS: Variants = { - normal: { - rotate: "0deg", - }, - animate: { - rotate: "-50deg", - }, -}; -const HAND_TRANSITION: Transition = { - duration: 0.6, - ease: [0.4, 0, 0.2, 1], -}; -const HAND_VARIANTS: Variants = { - normal: { - rotate: 0, - originX: "0%", - originY: "100%", - }, - animate: { - rotate: -360, - originX: "0%", - originY: "100%", - }, -}; -const MINUTE_HAND_TRANSITION: Transition = { - duration: 0.5, - ease: "easeInOut", -}; -const MINUTE_HAND_VARIANTS: Variants = { - normal: { - rotate: 0, - originX: "0%", - originY: "0%", - }, - animate: { - rotate: -45, - originX: "0%", - originY: "0%", - }, -}; -const HistoryIcon = forwardRef(({ 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) => { - if (isControlledRef.current) { - onMouseEnter?.(e); - } - else { - controls.start("animate"); - } - }, [controls, onMouseEnter]); - const handleMouseLeave = useCallback((e: React.MouseEvent) => { - if (isControlledRef.current) { - onMouseLeave?.(e); - } - else { - controls.start("normal"); - } - }, [controls, onMouseLeave]); - return (
- - - - - - - - -
); -}); -HistoryIcon.displayName = "HistoryIcon"; -export { HistoryIcon }; diff --git a/frontend/src/components/ui/home.tsx b/frontend/src/components/ui/home.tsx deleted file mode 100644 index 8b6eb25..0000000 --- a/frontend/src/components/ui/home.tsx +++ /dev/null @@ -1,62 +0,0 @@ -'use client'; -import type { Transition, Variants } from 'motion/react'; -import type { HTMLAttributes } from 'react'; -import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react'; -import { motion, useAnimation } from 'motion/react'; -import { cn } from '@/lib/utils'; -export interface HomeIconHandle { - startAnimation: () => void; - stopAnimation: () => void; -} -interface HomeIconProps extends HTMLAttributes { - size?: number; -} -const DEFAULT_TRANSITION: Transition = { - duration: 0.6, - opacity: { duration: 0.2 }, -}; -const PATH_VARIANTS: Variants = { - normal: { - pathLength: 1, - opacity: 1, - }, - animate: { - opacity: [0, 1], - pathLength: [0, 1], - }, -}; -const HomeIcon = forwardRef(({ 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) => { - if (!isControlledRef.current) { - controls.start('animate'); - } - else { - onMouseEnter?.(e); - } - }, [controls, onMouseEnter]); - const handleMouseLeave = useCallback((e: React.MouseEvent) => { - if (!isControlledRef.current) { - controls.start('normal'); - } - else { - onMouseLeave?.(e); - } - }, [controls, onMouseLeave]); - return (
- - - - -
); -}); -HomeIcon.displayName = 'HomeIcon'; -export { HomeIcon }; diff --git a/frontend/src/components/ui/input-with-context.tsx b/frontend/src/components/ui/input-with-context.tsx deleted file mode 100644 index 1c3ee0e..0000000 --- a/frontend/src/components/ui/input-with-context.tsx +++ /dev/null @@ -1,156 +0,0 @@ -import * as React from "react"; -import { Input } from "@/components/ui/input"; -import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger, } from "@/components/ui/context-menu"; -import { Scissors, Copy, Clipboard, Type } from "lucide-react"; -export interface InputWithContextProps extends React.InputHTMLAttributes { - onValueChange?: (value: string) => void; -} -const InputWithContext = React.forwardRef(({ className, type, onValueChange, onChange, ...props }, ref) => { - const inputRef = React.useRef(null); - const [hasSelection, setHasSelection] = React.useState(false); - const [canPaste, setCanPaste] = React.useState(false); - React.useImperativeHandle(ref, () => inputRef.current as HTMLInputElement); - const updateSelectionState = () => { - const input = inputRef.current; - if (!input) - return; - const start = input.selectionStart ?? 0; - const end = input.selectionEnd ?? 0; - setHasSelection(start !== end); - }; - const checkClipboard = async () => { - try { - const text = await navigator.clipboard.readText(); - setCanPaste(text.length > 0); - } - catch { - setCanPaste(false); - } - }; - const handleCut = async () => { - const input = inputRef.current; - if (!input) - return; - const start = input.selectionStart ?? 0; - const end = input.selectionEnd ?? 0; - const selectedText = input.value.substring(start, end); - if (selectedText) { - try { - await navigator.clipboard.writeText(selectedText); - const newValue = input.value.substring(0, start) + input.value.substring(end); - input.value = newValue; - input.setSelectionRange(start, start); - if (onChange) { - const event = { - target: input, - currentTarget: input, - } as React.ChangeEvent; - onChange(event); - } - if (onValueChange) { - onValueChange(newValue); - } - input.focus(); - } - catch (err) { - console.error("Failed to cut:", err); - } - } - }; - const handleCopy = async () => { - const input = inputRef.current; - if (!input) - return; - const start = input.selectionStart ?? 0; - const end = input.selectionEnd ?? 0; - const selectedText = input.value.substring(start, end); - if (selectedText) { - try { - await navigator.clipboard.writeText(selectedText); - input.focus(); - } - catch (err) { - console.error("Failed to copy:", err); - } - } - }; - const handlePaste = async () => { - const input = inputRef.current; - if (!input) - return; - try { - const text = await navigator.clipboard.readText(); - const start = input.selectionStart ?? 0; - const end = input.selectionEnd ?? 0; - const newValue = input.value.substring(0, start) + text + input.value.substring(end); - input.value = newValue; - const newPosition = start + text.length; - input.setSelectionRange(newPosition, newPosition); - if (onChange) { - const event = { - target: input, - currentTarget: input, - } as React.ChangeEvent; - onChange(event); - } - if (onValueChange) { - onValueChange(newValue); - } - input.focus(); - await checkClipboard(); - } - catch (err) { - console.error("Failed to paste:", err); - } - }; - const handleSelectAll = () => { - const input = inputRef.current; - if (!input) - return; - input.select(); - input.focus(); - updateSelectionState(); - }; - const handleInputChange = (e: React.ChangeEvent) => { - if (onChange) { - onChange(e); - } - if (onValueChange) { - onValueChange(e.target.value); - } - }; - return ( { - if (open) { - checkClipboard(); - } - }}> - - - - - - - Cut - Ctrl+X - - - - Copy - Ctrl+C - - - - Paste - Ctrl+V - - - - - Select All - Ctrl+A - - - ); -}); -InputWithContext.displayName = "InputWithContext"; -export { InputWithContext }; diff --git a/frontend/src/components/ui/input.tsx b/frontend/src/components/ui/input.tsx deleted file mode 100644 index 3a6e9b1..0000000 --- a/frontend/src/components/ui/input.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import * as React from "react"; -import { cn } from "@/lib/utils"; -function Input({ className, type, ...props }: React.ComponentProps<"input">) { - return (); -} -export { Input }; diff --git a/frontend/src/components/ui/label.tsx b/frontend/src/components/ui/label.tsx deleted file mode 100644 index e4ef7b3..0000000 --- a/frontend/src/components/ui/label.tsx +++ /dev/null @@ -1,8 +0,0 @@ -"use client"; -import * as React from "react"; -import * as LabelPrimitive from "@radix-ui/react-label"; -import { cn } from "@/lib/utils"; -function Label({ className, ...props }: React.ComponentProps) { - return (); -} -export { Label }; diff --git a/frontend/src/components/ui/menubar.tsx b/frontend/src/components/ui/menubar.tsx deleted file mode 100644 index 2dfd7f2..0000000 --- a/frontend/src/components/ui/menubar.tsx +++ /dev/null @@ -1,60 +0,0 @@ -"use client"; -import * as React from "react"; -import * as MenubarPrimitive from "@radix-ui/react-menubar"; -import { Check, ChevronRight, Circle } from "lucide-react"; -import { cn } from "@/lib/utils"; -const Menubar = React.forwardRef, React.ComponentPropsWithoutRef>(({ className, ...props }, ref) => ()); -Menubar.displayName = MenubarPrimitive.Root.displayName; -const MenubarMenu = MenubarPrimitive.Menu; -const MenubarGroup = MenubarPrimitive.Group; -const MenubarPortal = MenubarPrimitive.Portal; -const MenubarSub = MenubarPrimitive.Sub; -const MenubarRadioGroup = MenubarPrimitive.RadioGroup; -const MenubarTrigger = React.forwardRef, React.ComponentPropsWithoutRef>(({ className, ...props }, ref) => ()); -MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName; -const MenubarSubTrigger = React.forwardRef, React.ComponentPropsWithoutRef & { - inset?: boolean; -}>(({ className, inset, children, ...props }, ref) => ( - {children} - - )); -MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName; -const MenubarSubContent = React.forwardRef, React.ComponentPropsWithoutRef>(({ className, ...props }, ref) => ()); -MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName; -const MenubarContent = React.forwardRef, React.ComponentPropsWithoutRef>(({ className, align = "start", alignOffset = -4, sideOffset = 8, ...props }, ref) => ( - - )); -MenubarContent.displayName = MenubarPrimitive.Content.displayName; -const MenubarItem = React.forwardRef, React.ComponentPropsWithoutRef & { - inset?: boolean; -}>(({ className, inset, ...props }, ref) => ()); -MenubarItem.displayName = MenubarPrimitive.Item.displayName; -const MenubarCheckboxItem = React.forwardRef, React.ComponentPropsWithoutRef>(({ className, children, checked, ...props }, ref) => ( - - - - - - {children} - )); -MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName; -const MenubarRadioItem = React.forwardRef, React.ComponentPropsWithoutRef>(({ className, children, ...props }, ref) => ( - - - - - - {children} - )); -MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName; -const MenubarLabel = React.forwardRef, React.ComponentPropsWithoutRef & { - inset?: boolean; -}>(({ className, inset, ...props }, ref) => ()); -MenubarLabel.displayName = MenubarPrimitive.Label.displayName; -const MenubarSeparator = React.forwardRef, React.ComponentPropsWithoutRef>(({ className, ...props }, ref) => ()); -MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName; -const MenubarShortcut = ({ className, ...props }: React.HTMLAttributes) => { - return (); -}; -MenubarShortcut.displayname = "MenubarShortcut"; -export { Menubar, MenubarMenu, MenubarTrigger, MenubarContent, MenubarItem, MenubarSeparator, MenubarLabel, MenubarCheckboxItem, MenubarRadioGroup, MenubarRadioItem, MenubarPortal, MenubarSubContent, MenubarSubTrigger, MenubarSub, MenubarGroup, MenubarShortcut, }; diff --git a/frontend/src/components/ui/pagination.tsx b/frontend/src/components/ui/pagination.tsx deleted file mode 100644 index 0ac29c8..0000000 --- a/frontend/src/components/ui/pagination.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import * as React from "react"; -import { ChevronLeftIcon, ChevronRightIcon, MoreHorizontalIcon, } from "lucide-react"; -import { cn } from "@/lib/utils"; -import { Button, buttonVariants } from "@/components/ui/button"; -function Pagination({ className, ...props }: React.ComponentProps<"nav">) { - return (