diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..dc5441f
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,60 @@
+# 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
+
+# Build notes (optional - uncomment if you don't want to commit)
+# BUILD_NOTES.md
diff --git a/SpotiFLAC.py b/SpotiFLAC.py
deleted file mode 100644
index 2ed7c35..0000000
--- a/SpotiFLAC.py
+++ /dev/null
@@ -1,2294 +0,0 @@
-import sys
-import os
-from dataclasses import dataclass
-from datetime import datetime
-from pathlib import Path
-import requests
-import re
-import asyncio
-import json
-from packaging import version
-import qdarktheme
-from mutagen.flac import FLAC
-
-from PyQt6.QtWidgets import (
- QApplication, QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLineEdit,
- QLabel, QFileDialog, QListWidget, QTextEdit, QTabWidget, QButtonGroup, QRadioButton,
- QAbstractItemView, QProgressBar, QCheckBox, QDialog,
- QDialogButtonBox, QComboBox, QStyledItemDelegate, QMessageBox
-)
-from PyQt6.QtCore import Qt, QThread, pyqtSignal, QUrl, QTimer, QTime, QSettings, QSize
-from PyQt6.QtGui import QIcon, QTextCursor, QDesktopServices, QPixmap, QBrush, QPainter, QColor
-from PyQt6.QtSvg import QSvgRenderer
-from PyQt6.QtNetwork import QNetworkAccessManager, QNetworkRequest, QNetworkReply
-
-from getMetadata import get_filtered_data, parse_uri, SpotifyInvalidUrlException
-from tidalDL import TidalDownloader
-from deezerDL import DeezerDownloader
-from getSecret import scrape_and_save
-
-@dataclass
-class Track:
- external_urls: str
- title: str
- artists: str
- album: str
- track_number: int
- duration_ms: int
- id: str
- isrc: str = ""
- release_date: str = ""
-
-class SecretScrapeWorker(QThread):
- finished = pyqtSignal(bool, str)
- progress = pyqtSignal(str)
-
- def run(self):
- try:
- self.progress.emit("Fixing error...")
- self.progress.emit("Please wait, this may take a moment...")
-
- success, message = scrape_and_save(progress_callback=self.progress.emit)
-
- if success:
- self.finished.emit(True, "Fixed successfully!")
- else:
- self.finished.emit(False, message)
-
- except Exception as e:
- self.finished.emit(False, f"Error: {str(e)}")
-
-class MetadataFetchWorker(QThread):
- finished = pyqtSignal(dict)
- error = pyqtSignal(str)
-
- def __init__(self, url):
- super().__init__()
- self.url = url
-
- def run(self):
- try:
- metadata = get_filtered_data(self.url)
- if "error" in metadata:
- self.error.emit(metadata["error"])
- else:
- self.finished.emit(metadata)
- except SpotifyInvalidUrlException as e:
- self.error.emit(str(e))
- except Exception as e:
- self.error.emit(f'Failed to fetch metadata: {str(e)}')
-
-class DownloadWorker(QThread):
- finished = pyqtSignal(bool, str, list, list, list)
- progress = pyqtSignal(str, int)
-
- def __init__(self, tracks, outpath, is_single_track=False, is_album=False, is_playlist=False,
- album_or_playlist_name='', filename_format='title_artist', use_track_numbers=True,
- use_artist_subfolders=False, use_album_subfolders=False, service="tidal", tidal_api_url=None):
- super().__init__()
- self.tracks = tracks
- self.outpath = outpath
- self.is_single_track = is_single_track
- self.is_album = is_album
- self.is_playlist = is_playlist
- self.album_or_playlist_name = album_or_playlist_name
- self.filename_format = filename_format
- self.use_track_numbers = use_track_numbers
- self.use_artist_subfolders = use_artist_subfolders
- self.use_album_subfolders = use_album_subfolders
- self.service = service
- self.tidal_api_url = tidal_api_url
- self.is_paused = False
- self.is_stopped = False
- self.failed_tracks = []
- self.successful_tracks = []
- self.skipped_tracks = []
-
- def get_flac_isrc(self, filepath):
- try:
- audio = FLAC(filepath)
- if 'isrc' in audio:
- return audio['isrc'][0]
- except Exception:
- pass
- return None
-
- def get_formatted_filename(self, track):
- if self.filename_format == "artist_title":
- filename = f"{track.artists} - {track.title}.flac"
- elif self.filename_format == "title_only":
- filename = f"{track.title}.flac"
- else:
- filename = f"{track.title} - {track.artists}.flac"
- return re.sub(r'[<>:"/\\|?*]', lambda m: "'" if m.group() == '"' else '_', filename)
-
- def run(self):
- try:
- if self.service == "tidal":
- downloader = TidalDownloader(api_url=self.tidal_api_url)
- deezer_downloader = DeezerDownloader()
- elif self.service == "deezer":
- downloader = DeezerDownloader()
- deezer_downloader = None
- else:
- downloader = TidalDownloader(api_url=self.tidal_api_url)
- deezer_downloader = DeezerDownloader()
-
- def progress_update(current, total):
- if total <= 0:
- self.progress.emit("Processing metadata...", 0)
-
- downloader.set_progress_callback(progress_update)
-
- total_tracks = len(self.tracks)
-
- for i, track in enumerate(self.tracks):
- while self.is_paused:
- if self.is_stopped:
- return
- self.msleep(100)
- if self.is_stopped:
- return
-
- self.progress.emit(f"Starting download ({i+1}/{total_tracks}): {track.title} - {track.artists}",
- int((i) / total_tracks * 100))
-
- try:
- if self.is_playlist:
- track_outpath = self.outpath
-
- if self.use_artist_subfolders:
- artist_name = track.artists.split(', ')[0] if ', ' in track.artists else track.artists
- artist_folder = re.sub(r'[<>:"/\\|?*]', lambda m: "'" if m.group() == '"' else '_', artist_name)
- artist_folder = artist_folder.rstrip('. ')
- track_outpath = os.path.join(track_outpath, artist_folder)
-
- if self.use_album_subfolders:
- album_folder = re.sub(r'[<>:"/\\|?*]', lambda m: "'" if m.group() == '"' else '_', track.album)
- album_folder = album_folder.rstrip('. ')
- track_outpath = os.path.join(track_outpath, album_folder)
-
- os.makedirs(track_outpath, exist_ok=True)
- else:
- track_outpath = self.outpath
-
- spotify_isrc = track.isrc
- if spotify_isrc:
- is_already_downloaded = False
- try:
- for filename in os.listdir(track_outpath):
- if filename.lower().endswith('.flac'):
- filepath = os.path.join(track_outpath, filename)
- local_isrc = self.get_flac_isrc(filepath)
- if local_isrc and local_isrc == spotify_isrc:
- self.progress.emit(f"Skipped: Track with matching ISRC '{spotify_isrc}' already exists ('{filename}').", 0)
- self.progress.emit(f"Skipped: {track.title} - {track.artists}",
- int((i + 1) / total_tracks * 100))
- self.skipped_tracks.append(track)
- is_already_downloaded = True
- break
- except FileNotFoundError:
- pass
-
- if is_already_downloaded:
- continue
-
- if (self.is_album or self.is_playlist) and self.use_track_numbers:
- new_filename = f"{track.track_number:02d} - {self.get_formatted_filename(track)}"
- else:
- new_filename = self.get_formatted_filename(track)
-
- new_filename = re.sub(r'[<>:"/\\|?*]', lambda m: "'" if m.group() == '"' else '_', new_filename)
- new_filepath = os.path.join(track_outpath, new_filename)
-
- if os.path.exists(new_filepath) and os.path.getsize(new_filepath) > 0:
- self.progress.emit(f"File already exists by name: {new_filename}. Skipping download.", 0)
- self.progress.emit(f"Skipped: {track.title} - {track.artists}",
- int((i + 1) / total_tracks * 100))
- self.skipped_tracks.append(track)
- continue
-
- if self.service == "tidal":
- if not track.isrc:
- self.progress.emit(f"No ISRC found for track: {track.title}. Skipping.", 0)
- self.failed_tracks.append((track.title, track.artists, "No ISRC available"))
- continue
-
- self.progress.emit(f"Searching and downloading from Tidal for ISRC: {track.isrc} - {track.title} - {track.artists}", 0)
- is_paused_callback = lambda: self.is_paused
- is_stopped_callback = lambda: self.is_stopped
-
- auto_fallback = (self.tidal_api_url == "auto")
-
- download_result_details = downloader.download(
- query=f"{track.title} {track.artists}",
- isrc=track.isrc,
- output_dir=track_outpath,
- quality="LOSSLESS",
- is_paused_callback=is_paused_callback,
- is_stopped_callback=is_stopped_callback,
- auto_fallback=auto_fallback
- )
-
- if isinstance(download_result_details, str) and os.path.exists(download_result_details):
- downloaded_file = download_result_details
- elif isinstance(download_result_details, dict) and download_result_details.get("success") == False and download_result_details.get("error") == "Download stopped by user":
- self.progress.emit(f"Download stopped by user for: {track.title}",0)
- return
- elif isinstance(download_result_details, dict) and download_result_details.get("success") == False:
- raise Exception(download_result_details.get("error", "Tidal download failed"))
- elif isinstance(download_result_details, dict) and (download_result_details.get("status") == "all_skipped" or download_result_details.get("status") == "skipped_exists"):
- self.progress.emit(f"File already exists or skipped: {new_filename}",0)
- downloaded_file = new_filepath
- self.skipped_tracks.append(track)
- else:
- downloaded_file = None
- raise Exception(f"Tidal download failed or returned unexpected result: {download_result_details}")
- elif self.service == "deezer":
- if not track.isrc:
- self.progress.emit(f"No ISRC found for track: {track.title}. Skipping.", 0)
- self.failed_tracks.append((track.title, track.artists, "No ISRC available"))
- continue
-
- self.progress.emit(f"Downloading from Deezer with ISRC: {track.isrc}", 0)
-
- success = asyncio.run(downloader.download_by_isrc(track.isrc, track_outpath))
-
- if success:
- safe_title = "".join(c for c in track.title if c.isalnum() or c in (' ', '-', '_')).rstrip()
- safe_artist = "".join(c for c in track.artists if c.isalnum() or c in (' ', '-', '_')).rstrip()
- expected_filename = f"{safe_artist} - {safe_title}.flac"
- downloaded_file = os.path.join(track_outpath, expected_filename)
-
- if not os.path.exists(downloaded_file):
- import glob
- flac_files = glob.glob(os.path.join(track_outpath, "*.flac"))
- if flac_files:
- downloaded_file = max(flac_files, key=os.path.getctime)
- else:
- raise Exception("Downloaded file not found")
- else:
- raise Exception("Deezer download failed")
- else:
- track_id = track.id
- self.progress.emit(f"Getting track info for ID: {track_id} from {self.service}", 0)
-
- try:
- loop = asyncio.get_event_loop()
- if loop.is_closed():
- loop = asyncio.new_event_loop()
- asyncio.set_event_loop(loop)
- except RuntimeError:
- loop = asyncio.new_event_loop()
- asyncio.set_event_loop(loop)
-
- metadata = loop.run_until_complete(downloader.get_track_info(track_id, self.service))
- self.progress.emit(f"Track info received, starting download process", 0)
-
- is_paused_callback = lambda: self.is_paused
- is_stopped_callback = lambda: self.is_stopped
-
- downloaded_file = downloader.download(
- metadata,
- track_outpath,
- is_paused_callback=is_paused_callback,
- is_stopped_callback=is_stopped_callback
- )
- if self.is_stopped:
- return
-
- if downloaded_file and os.path.exists(downloaded_file):
- if downloaded_file == new_filepath:
- self.progress.emit(f"File already exists: {new_filename}", 0)
- self.progress.emit(f"Skipped: {track.title} - {track.artists}",
- int((i + 1) / total_tracks * 100))
- self.skipped_tracks.append(track)
- continue
-
- if downloaded_file != new_filepath:
- try:
- os.rename(downloaded_file, new_filepath)
- self.progress.emit(f"File renamed to: {new_filename}", 0)
- except OSError as e:
- self.progress.emit(f"Warning: Could not rename file {downloaded_file} to {new_filepath}: {str(e)}", 0)
- pass
- else:
- raise Exception(f"Download failed or file not found: {downloaded_file}")
-
- self.progress.emit(f"Successfully downloaded: {track.title} - {track.artists}",
- int((i + 1) / total_tracks * 100))
- self.successful_tracks.append(track)
- except Exception as e:
- if self.service == "tidal" and deezer_downloader and track.isrc:
- try:
- self.progress.emit(f"Tidal failed, trying Deezer fallback for: {track.title}", 0)
-
- success = asyncio.run(deezer_downloader.download_by_isrc(track.isrc, track_outpath))
-
- if success:
- safe_title = "".join(c for c in track.title if c.isalnum() or c in (' ', '-', '_')).rstrip()
- safe_artist = "".join(c for c in track.artists if c.isalnum() or c in (' ', '-', '_')).rstrip()
- expected_filename = f"{safe_artist} - {safe_title}.flac"
- downloaded_file = os.path.join(track_outpath, expected_filename)
-
- if not os.path.exists(downloaded_file):
- import glob
- flac_files = glob.glob(os.path.join(track_outpath, "*.flac"))
- if flac_files:
- downloaded_file = max(flac_files, key=os.path.getctime)
- else:
- raise Exception("Downloaded file not found")
-
- if downloaded_file != new_filepath:
- try:
- os.rename(downloaded_file, new_filepath)
- self.progress.emit(f"File renamed to: {new_filename}", 0)
- except OSError:
- pass
-
- self.progress.emit(f"Successfully downloaded via Deezer fallback: {track.title} - {track.artists}",
- int((i + 1) / total_tracks * 100))
- self.successful_tracks.append(track)
- continue
- else:
- raise Exception("Deezer fallback also failed")
- except Exception as deezer_error:
- self.progress.emit(f"Deezer fallback also failed: {str(deezer_error)}", 0)
- self.failed_tracks.append((track.title, track.artists, f"Tidal: {str(e)}, Deezer: {str(deezer_error)}"))
- self.progress.emit(f"Failed to download: {track.title} - {track.artists}\nBoth services failed",
- int((i + 1) / total_tracks * 100))
- continue
-
- self.failed_tracks.append((track.title, track.artists, str(e)))
- self.progress.emit(f"Failed to download: {track.title} - {track.artists}\nError: {str(e)}",
- int((i + 1) / total_tracks * 100))
- continue
-
- if not self.is_stopped:
- success_message = "Download completed!"
- if self.failed_tracks:
- success_message += f"\n\nFailed downloads: {len(self.failed_tracks)} tracks"
- if self.successful_tracks:
- success_message += f"\n\nSuccessful downloads: {len(self.successful_tracks)} tracks"
- if self.skipped_tracks:
- success_message += f"\n\nSkipped (already exists): {len(self.skipped_tracks)} tracks"
- self.finished.emit(True, success_message, self.failed_tracks, self.successful_tracks, self.skipped_tracks)
-
- except Exception as e:
- self.finished.emit(False, str(e), self.failed_tracks, self.successful_tracks, self.skipped_tracks)
-
- def pause(self):
- self.is_paused = True
- self.progress.emit("Download process paused.", 0)
-
- def resume(self):
- self.is_paused = False
- self.progress.emit("Download process resumed.", 0)
-
- def stop(self):
- self.is_stopped = True
- self.is_paused = False
-
-class UpdateDialog(QDialog):
- def __init__(self, current_version, new_version, parent=None):
- super().__init__(parent)
- self.setWindowTitle("Update Now")
- self.setFixedWidth(400)
- self.setModal(True)
-
- layout = QVBoxLayout()
-
- message = QLabel(f"SpotiFLAC v{new_version} Available!")
- message.setWordWrap(True)
- layout.addWidget(message)
-
- button_box = QDialogButtonBox()
- self.update_button = QPushButton("Check")
- self.update_button.setCursor(Qt.CursorShape.PointingHandCursor)
- self.cancel_button = QPushButton("Later")
- self.cancel_button.setCursor(Qt.CursorShape.PointingHandCursor)
-
- button_box.addButton(self.update_button, QDialogButtonBox.ButtonRole.AcceptRole)
- button_box.addButton(self.cancel_button, QDialogButtonBox.ButtonRole.RejectRole)
-
- layout.addWidget(button_box)
-
- self.setLayout(layout)
-
- self.update_button.clicked.connect(self.accept)
- self.cancel_button.clicked.connect(self.reject)
-
-class ServiceStatusDelegate(QStyledItemDelegate):
- def paint(self, painter, option, index):
- item_data = index.data(Qt.ItemDataRole.UserRole)
- is_online = item_data.get('online', False) if item_data else False
-
- super().paint(painter, option, index)
-
- indicator_color = Qt.GlobalColor.green if is_online else Qt.GlobalColor.red
-
- circle_size = 6
- circle_y = option.rect.center().y() - circle_size // 2
- circle_x = option.rect.right() - circle_size - 5
-
- painter.save()
- painter.setPen(Qt.PenStyle.NoPen)
- painter.setBrush(QBrush(indicator_color))
- painter.drawEllipse(circle_x, circle_y, circle_size, circle_size)
- painter.restore()
-
-class TidalAPIDelegate(QStyledItemDelegate):
- def paint(self, painter, option, index):
- item_data = index.data(Qt.ItemDataRole.UserRole + 1)
-
- super().paint(painter, option, index)
-
- if item_data and isinstance(item_data, dict) and 'status' in item_data:
- is_online = item_data.get('status') == 'UP'
- indicator_color = Qt.GlobalColor.green if is_online else Qt.GlobalColor.red
-
- circle_size = 6
- circle_y = option.rect.center().y() - circle_size // 2
- circle_x = option.rect.right() - circle_size - 5
-
- painter.save()
- painter.setPen(Qt.PenStyle.NoPen)
- painter.setBrush(QBrush(indicator_color))
- painter.drawEllipse(circle_x, circle_y, circle_size, circle_size)
- painter.restore()
-
-class TidalStatusChecker(QThread):
- status_updated = pyqtSignal(bool)
- error = pyqtSignal(str)
-
- def check_single_api(self, api):
- try:
- url = api.get('url', '')
- test_response = requests.get(f"{url}/track/?id=251380837&quality=LOSSLESS", timeout=5)
- return test_response.status_code == 200
- except:
- return False
-
- def run(self):
- try:
- from concurrent.futures import ThreadPoolExecutor, as_completed
-
- apis = TidalDownloader.get_available_apis()
-
- if not apis:
- self.status_updated.emit(False)
- return
-
- any_online = False
- with ThreadPoolExecutor(max_workers=10) as executor:
- futures = {executor.submit(self.check_single_api, api): api for api in apis}
-
- for future in as_completed(futures):
- try:
- if future.result():
- any_online = True
- for f in futures:
- f.cancel()
- break
- except:
- continue
-
- self.status_updated.emit(any_online)
- except Exception as e:
- self.error.emit(f"Error checking Tidal status: {str(e)}")
- self.status_updated.emit(False)
-
-class DeezerStatusChecker(QThread):
- status_updated = pyqtSignal(bool)
- error = pyqtSignal(str)
-
- def run(self):
- try:
- response = requests.get("https://deezmate.com/", timeout=5)
- is_online = response.status_code == 200
- self.status_updated.emit(is_online)
- except Exception as e:
- self.error.emit(f"Error checking Deezer status: {str(e)}")
- self.status_updated.emit(False)
-
-class APIStatusChecker(QThread):
- status_checked = pyqtSignal(str, str)
- all_completed = pyqtSignal()
-
- def __init__(self, apis):
- super().__init__()
- self.apis = apis
-
- def check_single_api(self, api):
- url = api.get('url', '')
- try:
- test_response = requests.get(f"{url}/track/?id=251380837&quality=LOSSLESS", timeout=5)
- is_online = test_response.status_code == 200
- status = 'UP' if is_online else 'DOWN'
- except:
- status = 'DOWN'
- return (url, status)
-
- def run(self):
- from concurrent.futures import ThreadPoolExecutor, as_completed
-
- with ThreadPoolExecutor(max_workers=10) as executor:
- futures = {executor.submit(self.check_single_api, api): api for api in self.apis}
-
- for future in as_completed(futures):
- try:
- url, status = future.result()
- self.status_checked.emit(url, status)
- except Exception as e:
- print(f"Error checking API: {e}")
-
- self.all_completed.emit()
-
-class ServiceComboBox(QComboBox):
- def __init__(self, parent=None):
- super().__init__(parent)
- self.setIconSize(QSize(16, 16))
- self.setItemDelegate(ServiceStatusDelegate())
- self.setup_items()
-
- QTimer.singleShot(100, self.start_tidal_status_check)
- QTimer.singleShot(100, self.start_deezer_status_check)
-
- self.tidal_status_timer = QTimer(self)
- self.tidal_status_timer.timeout.connect(self.refresh_tidal_status)
- self.tidal_status_timer.start(60000)
-
- self.deezer_status_timer = QTimer(self)
- self.deezer_status_timer.timeout.connect(self.refresh_deezer_status)
- self.deezer_status_timer.start(60000)
-
- def start_tidal_status_check(self):
- self.tidal_status_checker = TidalStatusChecker()
- self.tidal_status_checker.status_updated.connect(self.update_tidal_status)
- self.tidal_status_checker.error.connect(lambda e: print(f"Tidal status check error: {e}"))
- self.tidal_status_checker.start()
-
- def start_deezer_status_check(self):
- self.deezer_status_checker = DeezerStatusChecker()
- self.deezer_status_checker.status_updated.connect(self.update_deezer_status)
- self.deezer_status_checker.error.connect(lambda e: print(f"Deezer status check error: {e}"))
- self.deezer_status_checker.start()
-
- def setup_items(self):
- current_dir = os.path.dirname(os.path.abspath(__file__))
-
- self.services = [
- {'id': 'tidal', 'name': 'Tidal', 'icon': 'icons/tidal.png', 'online': False},
- {'id': 'deezer', 'name': 'Deezer', 'icon': 'icons/deezer.png', 'online': False}
- ]
-
- for service in self.services:
- icon_path = os.path.join(current_dir, service['icon'])
- if not os.path.exists(icon_path):
- self.create_placeholder_icon(icon_path)
-
- icon = QIcon(icon_path)
-
- self.addItem(icon, service['name'])
- item_index = self.count() - 1
- self.setItemData(item_index, service['id'], Qt.ItemDataRole.UserRole + 1)
- self.setItemData(item_index, service, Qt.ItemDataRole.UserRole)
- def create_placeholder_icon(self, path):
- pixmap = QPixmap(16, 16)
- pixmap.fill(Qt.GlobalColor.transparent)
- pixmap.save(path)
-
- def update_tidal_status(self, is_online):
- for i in range(self.count()):
- service_id = self.itemData(i, Qt.ItemDataRole.UserRole + 1)
- if service_id == 'tidal':
- service_data = self.itemData(i, Qt.ItemDataRole.UserRole)
- if isinstance(service_data, dict):
- service_data['online'] = is_online
- self.setItemData(i, service_data, Qt.ItemDataRole.UserRole)
- break
- self.update()
-
- def refresh_tidal_status(self):
- if hasattr(self, 'tidal_status_checker') and self.tidal_status_checker.isRunning():
- self.tidal_status_checker.quit()
- self.tidal_status_checker.wait()
-
- self.tidal_status_checker = TidalStatusChecker()
- self.tidal_status_checker.status_updated.connect(self.update_tidal_status)
- self.tidal_status_checker.error.connect(lambda e: print(f"Tidal status check error: {e}"))
- self.tidal_status_checker.start()
-
- def update_deezer_status(self, is_online):
- for i in range(self.count()):
- service_id = self.itemData(i, Qt.ItemDataRole.UserRole + 1)
- if service_id == 'deezer':
- service_data = self.itemData(i, Qt.ItemDataRole.UserRole)
- if isinstance(service_data, dict):
- service_data['online'] = is_online
- self.setItemData(i, service_data, Qt.ItemDataRole.UserRole)
- break
- self.update()
-
- def refresh_deezer_status(self):
- if hasattr(self, 'deezer_status_checker') and self.deezer_status_checker.isRunning():
- self.deezer_status_checker.quit()
- self.deezer_status_checker.wait()
-
- self.deezer_status_checker = DeezerStatusChecker()
- self.deezer_status_checker.status_updated.connect(self.update_deezer_status)
- self.deezer_status_checker.error.connect(lambda e: print(f"Deezer status check error: {e}"))
- self.deezer_status_checker.start()
-
- def currentData(self, role=Qt.ItemDataRole.UserRole + 1):
- return super().currentData(role)
-
-class SpotiFLACGUI(QWidget):
- def __init__(self):
- super().__init__()
- self.current_version = "5.4"
- self.tracks = []
- self.all_tracks = []
- self.successful_downloads = []
- self.reset_state()
-
- self.settings = QSettings('SpotiFLAC', 'Settings')
- self.last_output_path = self.settings.value('output_path', str(Path.home() / "Music"))
- self.last_url = self.settings.value('spotify_url', '')
-
- self.filename_format = self.settings.value('filename_format', 'title_artist')
- self.use_track_numbers = self.settings.value('use_track_numbers', False, type=bool)
- self.use_artist_subfolders = self.settings.value('use_artist_subfolders', False, type=bool)
- self.use_album_subfolders = self.settings.value('use_album_subfolders', False, type=bool)
- self.service = self.settings.value('service', 'tidal')
- self.tidal_api = self.settings.value('tidal_api', 'auto')
- self.check_for_updates = self.settings.value('check_for_updates', True, type=bool)
- self.current_theme_color = self.settings.value('theme_color', '#2196F3')
- self.track_list_format = self.settings.value('track_list_format', 'track_artist_date_duration')
- self.date_format = self.settings.value('date_format', 'dd_mm_yyyy')
-
- self.elapsed_time = QTime(0, 0, 0)
- self.timer = QTimer(self)
- self.timer.timeout.connect(self.update_timer)
-
- self.network_manager = QNetworkAccessManager()
- self.network_manager.finished.connect(self.on_cover_loaded)
-
- self.initUI()
-
- if self.check_for_updates:
- QTimer.singleShot(0, self.check_updates)
-
- def set_combobox_value(self, combobox, target_value):
- for i in range(combobox.count()):
- if combobox.itemData(i, Qt.ItemDataRole.UserRole + 1) == target_value:
- combobox.setCurrentIndex(i)
- return True
- if combobox.itemData(i, Qt.ItemDataRole.UserRole) == target_value:
- combobox.setCurrentIndex(i)
- return True
- return False
-
- def check_updates(self):
- try:
- response = requests.get("https://raw.githubusercontent.com/afkarxyz/SpotiFLAC/refs/heads/main/version.json")
- if response.status_code == 200:
- data = response.json()
- new_version = data.get("version")
-
- if new_version and version.parse(new_version) > version.parse(self.current_version):
- dialog = UpdateDialog(self.current_version, new_version, self)
- result = dialog.exec()
-
- if result == QDialog.DialogCode.Accepted:
- QDesktopServices.openUrl(QUrl("https://github.com/afkarxyz/SpotiFLAC/releases"))
-
- except Exception as e:
- pass
-
- @staticmethod
- def format_duration(ms):
- minutes = ms // 60000
- seconds = (ms % 60000) // 1000
- return f"{minutes}:{seconds:02d}"
-
- def reset_state(self):
- self.tracks.clear()
- self.all_tracks.clear()
- self.is_album = False
- self.is_playlist = False
- self.is_single_track = False
- self.album_or_playlist_name = ''
-
- def reset_ui(self):
- self.track_list.clear()
- self.track_list.show()
- self.log_output.clear()
- self.progress_bar.setValue(0)
- self.progress_bar.hide()
- self.stop_btn.hide()
- self.pause_resume_btn.hide()
- self.pause_resume_btn.setText('Pause')
- self.reset_info_widget()
- self.hide_track_buttons()
- if hasattr(self, 'search_input'):
- self.search_input.clear()
- if hasattr(self, 'search_widget'):
- self.search_widget.hide()
-
- def get_themed_icon(self, icon_name):
- icon_path = os.path.join(os.path.dirname(__file__), "icons", icon_name)
- if not os.path.exists(icon_path):
- return QIcon()
-
- with open(icon_path, 'r') as f:
- svg_content = f.read()
-
- svg_content = svg_content.replace('currentColor', self.current_theme_color)
-
- renderer = QSvgRenderer(svg_content.encode())
- pixmap = QPixmap(16, 16)
- pixmap.fill(QColor(0, 0, 0, 0))
-
- painter = QPainter(pixmap)
- renderer.render(painter)
- painter.end()
-
- return QIcon(pixmap)
-
- def initUI(self):
- self.setWindowTitle('SpotiFLAC')
- self.setFixedWidth(650)
- self.setMinimumHeight(350)
-
- icon_path = os.path.join(os.path.dirname(__file__), "icons", "icon.svg")
- if os.path.exists(icon_path):
- self.setWindowIcon(QIcon(icon_path))
-
- self.main_layout = QVBoxLayout()
-
- self.setup_spotify_section()
- self.setup_tabs()
-
- self.setLayout(self.main_layout)
-
- def setup_spotify_section(self):
- spotify_layout = QHBoxLayout()
- spotify_label = QLabel('Spotify URL:')
- spotify_label.setFixedWidth(100)
-
- self.spotify_url = QLineEdit()
- self.spotify_url.setPlaceholderText("Enter Spotify URL")
- self.spotify_url.setClearButtonEnabled(True)
- self.spotify_url.setText(self.last_url)
- self.spotify_url.textChanged.connect(self.save_url)
-
- self.fetch_btn = QPushButton('Fetch')
- self.fetch_btn.setFixedWidth(80)
- self.fetch_btn.setCursor(Qt.CursorShape.PointingHandCursor)
- self.fetch_btn.clicked.connect(self.fetch_tracks)
-
- spotify_layout.addWidget(spotify_label)
- spotify_layout.addWidget(self.spotify_url)
- spotify_layout.addWidget(self.fetch_btn)
- self.main_layout.addLayout(spotify_layout)
-
- def filter_tracks(self):
- search_text = self.search_input.text().lower().strip()
-
- if not search_text:
- self.tracks = self.all_tracks.copy()
- else:
- self.tracks = [
- track for track in self.all_tracks
- if (search_text in track.title.lower() or
- search_text in track.artists.lower() or
- search_text in track.album.lower())
- ]
-
- self.update_track_list_display()
-
- def format_track_date(self, release_date):
- if not release_date:
- return ""
-
- try:
- if len(release_date) == 4:
- date_obj = datetime.strptime(release_date, "%Y")
- if self.date_format == "yyyy":
- return date_obj.strftime('%Y')
- else:
- return date_obj.strftime('%Y')
- elif len(release_date) == 7:
- date_obj = datetime.strptime(release_date, "%Y-%m")
- if self.date_format == "dd_mm_yyyy":
- return date_obj.strftime('%m-%Y')
- elif self.date_format == "yyyy_mm_dd":
- return date_obj.strftime('%Y-%m')
- else:
- return date_obj.strftime('%Y')
- else:
- date_obj = datetime.strptime(release_date, "%Y-%m-%d")
- if self.date_format == "dd_mm_yyyy":
- return date_obj.strftime('%d-%m-%Y')
- elif self.date_format == "yyyy_mm_dd":
- return date_obj.strftime('%Y-%m-%d')
- else:
- return date_obj.strftime('%Y')
- except ValueError:
- return release_date
-
- def update_track_list_display(self):
- self.track_list.clear()
- for i, track in enumerate(self.tracks, 1):
- duration = self.format_duration(track.duration_ms)
- formatted_date = self.format_track_date(track.release_date)
-
- if self.track_list_format == "artist_track_date_duration":
- display_parts = [f"{i}. {track.artists} - {track.title}"]
- if formatted_date:
- display_parts.append(formatted_date)
- display_parts.append(duration)
- display_text = " • ".join(display_parts)
- elif self.track_list_format == "track_artist_date":
- display_parts = [f"{i}. {track.title} - {track.artists}"]
- if formatted_date:
- display_parts.append(formatted_date)
- display_text = " • ".join(display_parts)
- elif self.track_list_format == "artist_track_date":
- display_parts = [f"{i}. {track.artists} - {track.title}"]
- if formatted_date:
- display_parts.append(formatted_date)
- display_text = " • ".join(display_parts)
- elif self.track_list_format == "track_artist_duration":
- display_text = f"{i}. {track.title} - {track.artists} • {duration}"
- elif self.track_list_format == "artist_track_duration":
- display_text = f"{i}. {track.artists} - {track.title} • {duration}"
- elif self.track_list_format == "track_artist":
- display_text = f"{i}. {track.title} - {track.artists}"
- elif self.track_list_format == "artist_track":
- display_text = f"{i}. {track.artists} - {track.title}"
- else:
- display_parts = [f"{i}. {track.title} - {track.artists}"]
- if formatted_date:
- display_parts.append(formatted_date)
- display_parts.append(duration)
- display_text = " • ".join(display_parts)
-
- self.track_list.addItem(display_text)
-
- def browse_output(self):
- directory = QFileDialog.getExistingDirectory(self, "Select Output Directory")
- if directory:
- self.output_dir.setText(directory)
- self.save_settings()
-
- def setup_tabs(self):
- self.tab_widget = QTabWidget()
- self.main_layout.addWidget(self.tab_widget)
-
- self.setup_dashboard_tab()
- self.setup_process_tab()
- self.setup_settings_tab()
- self.setup_theme_tab()
- self.setup_about_tab()
-
- def setup_dashboard_tab(self):
- dashboard_tab = QWidget()
- dashboard_layout = QVBoxLayout()
-
- self.setup_info_widget()
- dashboard_layout.addWidget(self.info_widget)
-
- self.track_list = QListWidget()
- self.track_list.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
- dashboard_layout.addWidget(self.track_list)
-
- self.setup_track_buttons()
- dashboard_layout.addLayout(self.btn_layout)
- dashboard_layout.addWidget(self.single_track_container)
-
- dashboard_tab.setLayout(dashboard_layout)
- self.tab_widget.addTab(dashboard_tab, "Dashboard")
-
- self.hide_track_buttons()
-
- def setup_info_widget(self):
- self.info_widget = QWidget()
- info_layout = QHBoxLayout()
- self.cover_label = QLabel()
- self.cover_label.setFixedSize(80, 80)
- self.cover_label.setScaledContents(True)
- info_layout.addWidget(self.cover_label)
-
- text_info_layout = QVBoxLayout()
-
- self.title_label = QLabel()
- self.title_label.setStyleSheet("font-size: 14px; font-weight: bold;")
- self.title_label.setWordWrap(True)
-
- self.artists_label = QLabel()
- self.artists_label.setWordWrap(True)
-
- self.followers_label = QLabel()
- self.followers_label.setWordWrap(True)
-
- self.release_date_label = QLabel()
- self.release_date_label.setWordWrap(True)
-
- self.type_label = QLabel()
- self.type_label.setStyleSheet("font-size: 12px;")
-
- text_info_layout.addWidget(self.title_label)
- text_info_layout.addWidget(self.artists_label)
- text_info_layout.addWidget(self.followers_label)
- text_info_layout.addWidget(self.release_date_label)
- text_info_layout.addWidget(self.type_label)
- text_info_layout.addStretch()
-
- info_layout.addLayout(text_info_layout, 1)
-
- self.setup_search_widget()
- info_layout.addWidget(self.search_widget)
-
- self.info_widget.setLayout(info_layout)
- self.info_widget.setFixedHeight(100)
- self.info_widget.hide()
-
- def setup_search_widget(self):
- self.search_widget = QWidget()
- search_layout = QVBoxLayout()
- search_layout.setContentsMargins(10, 0, 0, 0)
-
- search_layout.addStretch()
-
- search_input_layout = QHBoxLayout()
- search_input_layout.addStretch()
-
- self.search_input = QLineEdit()
- self.search_input.setPlaceholderText("Search...")
- self.search_input.setClearButtonEnabled(True)
- self.search_input.textChanged.connect(self.filter_tracks)
- self.search_input.setFixedWidth(250)
-
- search_input_layout.addWidget(self.search_input)
- search_layout.addLayout(search_input_layout)
-
- self.search_widget.setLayout(search_layout)
- self.search_widget.hide()
-
- def setup_track_buttons(self):
- self.btn_layout = QHBoxLayout()
- self.download_btn = QPushButton(' Download')
- self.download_btn.setIcon(self.get_themed_icon('download.svg'))
- self.delete_btn = QPushButton(' Delete')
- self.delete_btn.setIcon(self.get_themed_icon('trash.svg'))
-
- for btn in [self.download_btn, self.delete_btn]:
- btn.setFixedWidth(120)
- btn.setCursor(Qt.CursorShape.PointingHandCursor)
-
- self.download_btn.clicked.connect(self.download_tracks_action)
- self.delete_btn.clicked.connect(self.delete_tracks)
-
- self.btn_layout.addStretch()
- self.btn_layout.addWidget(self.download_btn)
- self.btn_layout.addWidget(self.delete_btn)
- self.btn_layout.addStretch()
-
- self.single_track_container = QWidget()
- single_track_layout = QHBoxLayout(self.single_track_container)
- single_track_layout.setContentsMargins(0, 0, 0, 0)
-
- self.single_download_btn = QPushButton(' Download')
- self.single_download_btn.setIcon(self.get_themed_icon('download.svg'))
- self.single_delete_btn = QPushButton(' Delete')
- self.single_delete_btn.setIcon(self.get_themed_icon('trash.svg'))
-
- for btn in [self.single_download_btn, self.single_delete_btn]:
- btn.setFixedWidth(120)
- btn.setCursor(Qt.CursorShape.PointingHandCursor)
-
- self.single_download_btn.clicked.connect(self.download_tracks_action)
- self.single_delete_btn.clicked.connect(self.delete_tracks)
-
- single_track_layout.addStretch()
- single_track_layout.addWidget(self.single_download_btn)
- single_track_layout.addWidget(self.single_delete_btn)
- single_track_layout.addStretch()
-
- self.single_track_container.hide()
-
- def setup_process_tab(self):
- self.process_tab = QWidget()
- process_layout = QVBoxLayout()
- process_layout.setSpacing(5)
-
- self.log_output = QTextEdit()
- self.log_output.setReadOnly(True)
- process_layout.addWidget(self.log_output)
-
- fix_error_layout = QHBoxLayout()
- fix_error_layout.addStretch()
- self.fix_error_btn = QPushButton(' Fix Error')
- self.fix_error_btn.setIcon(self.get_themed_icon('tool.svg'))
- self.fix_error_btn.setFixedWidth(120)
- self.fix_error_btn.setCursor(Qt.CursorShape.PointingHandCursor)
- self.fix_error_btn.clicked.connect(self.fix_error_action)
- self.fix_error_btn.hide()
- fix_error_layout.addWidget(self.fix_error_btn)
- fix_error_layout.addStretch()
- process_layout.addLayout(fix_error_layout)
-
- progress_time_layout = QVBoxLayout()
- progress_time_layout.setSpacing(2)
-
- self.progress_bar = QProgressBar()
- progress_time_layout.addWidget(self.progress_bar)
-
- self.time_label = QLabel("00:00:00")
- self.time_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
- progress_time_layout.addWidget(self.time_label)
-
- process_layout.addLayout(progress_time_layout)
-
- control_layout = QHBoxLayout()
- self.stop_btn = QPushButton('Stop')
- self.pause_resume_btn = QPushButton('Pause')
-
- self.stop_btn.setFixedWidth(120)
- self.pause_resume_btn.setFixedWidth(120)
-
- self.stop_btn.setCursor(Qt.CursorShape.PointingHandCursor)
- self.pause_resume_btn.setCursor(Qt.CursorShape.PointingHandCursor)
-
- self.stop_btn.clicked.connect(self.stop_download)
- self.pause_resume_btn.clicked.connect(self.toggle_pause_resume)
-
- self.remove_successful_btn = QPushButton(' Remove Finished Tracks')
- self.remove_successful_btn.setIcon(self.get_themed_icon('circle-x.svg'))
- self.remove_successful_btn.setFixedWidth(200)
- self.remove_successful_btn.setCursor(Qt.CursorShape.PointingHandCursor)
- self.remove_successful_btn.clicked.connect(self.remove_successful_downloads)
-
- control_layout.addStretch()
- control_layout.addWidget(self.stop_btn)
- control_layout.addWidget(self.pause_resume_btn)
- control_layout.addWidget(self.remove_successful_btn)
- control_layout.addStretch()
-
- process_layout.addLayout(control_layout)
-
- self.process_tab.setLayout(process_layout)
-
- self.tab_widget.addTab(self.process_tab, "Process")
-
- self.progress_bar.hide()
- self.time_label.hide()
- self.stop_btn.hide()
- self.pause_resume_btn.hide()
- self.remove_successful_btn.hide()
-
- def setup_settings_tab(self):
- settings_tab = QWidget()
- settings_layout = QVBoxLayout()
- settings_layout.setSpacing(4)
- settings_layout.setContentsMargins(10, 10, 10, 10)
-
- output_group = QWidget()
- output_layout = QVBoxLayout(output_group)
- output_layout.setSpacing(2)
- output_layout.setContentsMargins(0, 0, 0, 0)
-
- output_label = QLabel('Output Directory')
- output_label.setStyleSheet("font-weight: bold; margin-top: 0px; margin-bottom: 5px;")
- output_layout.addWidget(output_label)
-
- output_dir_layout = QHBoxLayout()
- self.output_dir = QLineEdit()
- self.output_dir.setText(self.last_output_path)
- self.output_dir.textChanged.connect(self.save_settings)
-
- self.output_browse = QPushButton('Browse')
- self.output_browse.setFixedWidth(80)
- self.output_browse.setCursor(Qt.CursorShape.PointingHandCursor)
- self.output_browse.clicked.connect(self.browse_output)
-
- output_dir_layout.addWidget(self.output_dir)
- output_dir_layout.addSpacing(5)
- output_dir_layout.addWidget(self.output_browse)
-
- output_layout.addLayout(output_dir_layout)
-
- settings_layout.addWidget(output_group)
-
- dashboard_group = QWidget()
- dashboard_layout = QVBoxLayout(dashboard_group)
- dashboard_layout.setSpacing(3)
- dashboard_layout.setContentsMargins(0, 0, 0, 0)
-
- dashboard_label = QLabel('Dashboard Settings')
- dashboard_label.setStyleSheet("font-weight: bold; margin-top: 8px; margin-bottom: 5px;")
- dashboard_layout.addWidget(dashboard_label)
-
- dashboard_controls_layout = QHBoxLayout()
-
- list_format_label = QLabel('Track List View:')
- list_format_label.setFixedWidth(90)
-
- self.track_list_format_dropdown = QComboBox()
- self.track_list_format_dropdown.addItem("Track - Artist - Date - Duration", "track_artist_date_duration")
- self.track_list_format_dropdown.addItem("Artist - Track - Date - Duration", "artist_track_date_duration")
- self.track_list_format_dropdown.addItem("Track - Artist - Date", "track_artist_date")
- self.track_list_format_dropdown.addItem("Artist - Track - Date", "artist_track_date")
- self.track_list_format_dropdown.addItem("Track - Artist - Duration", "track_artist_duration")
- self.track_list_format_dropdown.addItem("Artist - Track - Duration", "artist_track_duration")
- self.track_list_format_dropdown.addItem("Track - Artist", "track_artist")
- self.track_list_format_dropdown.addItem("Artist - Track", "artist_track")
- self.track_list_format_dropdown.currentIndexChanged.connect(self.save_track_list_format)
-
- dashboard_controls_layout.addWidget(list_format_label)
- dashboard_controls_layout.addWidget(self.track_list_format_dropdown)
-
- dashboard_controls_layout.addSpacing(15)
-
- date_format_label = QLabel('Date Format:')
- date_format_label.setFixedWidth(80)
-
- self.date_format_dropdown = QComboBox()
- self.date_format_dropdown.addItem("DD-MM-YYYY", "dd_mm_yyyy")
- self.date_format_dropdown.addItem("YYYY-MM-DD", "yyyy_mm_dd")
- self.date_format_dropdown.addItem("YYYY", "yyyy")
- self.date_format_dropdown.currentIndexChanged.connect(self.save_date_format)
-
- dashboard_controls_layout.addWidget(date_format_label)
- dashboard_controls_layout.addWidget(self.date_format_dropdown)
- dashboard_controls_layout.addStretch()
-
- dashboard_layout.addLayout(dashboard_controls_layout)
-
- settings_layout.addWidget(dashboard_group)
-
- file_group = QWidget()
- file_layout = QVBoxLayout(file_group)
- file_layout.setSpacing(2)
- file_layout.setContentsMargins(0, 0, 0, 0)
-
- file_label = QLabel('File Settings')
- file_label.setStyleSheet("font-weight: bold; margin-top: 8px; margin-bottom: 5px;")
- file_layout.addWidget(file_label)
-
- format_layout = QHBoxLayout()
- format_label = QLabel('Filename Format:')
- self.format_group = QButtonGroup(self)
- self.title_artist_radio = QRadioButton('Title - Artist')
- self.title_artist_radio.setCursor(Qt.CursorShape.PointingHandCursor)
- self.title_artist_radio.toggled.connect(self.save_filename_format)
-
- self.artist_title_radio = QRadioButton('Artist - Title')
- self.artist_title_radio.setCursor(Qt.CursorShape.PointingHandCursor)
- self.artist_title_radio.toggled.connect(self.save_filename_format)
-
- self.title_only_radio = QRadioButton('Title')
- self.title_only_radio.setCursor(Qt.CursorShape.PointingHandCursor)
- self.title_only_radio.toggled.connect(self.save_filename_format)
-
- if hasattr(self, 'filename_format') and self.filename_format == "artist_title":
- self.artist_title_radio.setChecked(True)
- elif hasattr(self, 'filename_format') and self.filename_format == "title_only":
- self.title_only_radio.setChecked(True)
- else:
- self.title_artist_radio.setChecked(True)
-
- self.format_group.addButton(self.title_artist_radio)
- self.format_group.addButton(self.artist_title_radio)
- self.format_group.addButton(self.title_only_radio)
-
- format_layout.addWidget(format_label)
- format_layout.addWidget(self.title_artist_radio)
- format_layout.addSpacing(10)
- format_layout.addWidget(self.artist_title_radio)
- format_layout.addSpacing(10)
- format_layout.addWidget(self.title_only_radio)
- format_layout.addStretch()
- file_layout.addLayout(format_layout)
-
- checkbox_layout = QHBoxLayout()
-
- self.artist_subfolder_checkbox = QCheckBox('Artist Subfolder (Playlist)')
- self.artist_subfolder_checkbox.setCursor(Qt.CursorShape.PointingHandCursor)
- self.artist_subfolder_checkbox.setChecked(self.use_artist_subfolders)
- self.artist_subfolder_checkbox.toggled.connect(self.save_artist_subfolder_setting)
- checkbox_layout.addWidget(self.artist_subfolder_checkbox)
- checkbox_layout.addSpacing(10)
-
- self.album_subfolder_checkbox = QCheckBox('Album Subfolder (Playlist)')
- self.album_subfolder_checkbox.setCursor(Qt.CursorShape.PointingHandCursor)
- self.album_subfolder_checkbox.setChecked(self.use_album_subfolders)
- self.album_subfolder_checkbox.toggled.connect(self.save_album_subfolder_setting)
- checkbox_layout.addWidget(self.album_subfolder_checkbox)
- checkbox_layout.addSpacing(10)
-
- self.track_number_checkbox = QCheckBox('Track Number')
- self.track_number_checkbox.setCursor(Qt.CursorShape.PointingHandCursor)
- self.track_number_checkbox.setChecked(self.use_track_numbers)
- self.track_number_checkbox.toggled.connect(self.save_track_numbering)
- checkbox_layout.addWidget(self.track_number_checkbox)
-
- checkbox_layout.addStretch()
- file_layout.addLayout(checkbox_layout)
-
- settings_layout.addWidget(file_group)
-
- auth_group = QWidget()
- auth_layout = QVBoxLayout(auth_group)
- auth_layout.setSpacing(2)
- auth_layout.setContentsMargins(0, 0, 0, 0)
-
- auth_label = QLabel('Service Settings')
- auth_label.setStyleSheet("font-weight: bold; margin-top: 8px; margin-bottom: 5px;")
- auth_layout.addWidget(auth_label)
-
- service_api_layout = QHBoxLayout()
-
- service_label = QLabel('Service:')
- service_label.setFixedWidth(53)
-
- self.service_dropdown = ServiceComboBox()
- self.service_dropdown.setFixedWidth(100)
- self.service_dropdown.currentIndexChanged.connect(self.on_service_changed)
-
- service_api_layout.addWidget(service_label)
- service_api_layout.addWidget(self.service_dropdown)
-
- self.api_spacer = QWidget()
- self.api_spacer.setFixedWidth(15)
- service_api_layout.addWidget(self.api_spacer)
-
- self.tidal_api_label = QLabel('API Instances:')
- self.tidal_api_label.setFixedWidth(85)
-
- self.tidal_api_dropdown = QComboBox()
- self.tidal_api_dropdown.setItemDelegate(TidalAPIDelegate())
- self.tidal_api_dropdown.addItem("Auto Fallback", "auto")
- self.tidal_api_dropdown.currentIndexChanged.connect(self.on_tidal_api_changed)
-
- self.refresh_api_btn = QPushButton('Refresh')
- self.refresh_api_btn.setFixedWidth(80)
- self.refresh_api_btn.setCursor(Qt.CursorShape.PointingHandCursor)
- self.refresh_api_btn.clicked.connect(self.refresh_tidal_apis)
-
- service_api_layout.addWidget(self.tidal_api_label)
- service_api_layout.addWidget(self.tidal_api_dropdown, 2)
- service_api_layout.addSpacing(5)
- service_api_layout.addWidget(self.refresh_api_btn)
- service_api_layout.addStretch(1)
-
- auth_layout.addLayout(service_api_layout)
-
- self.refresh_tidal_apis()
-
- self.update_tidal_api_visibility()
-
- settings_layout.addWidget(auth_group)
- settings_layout.addStretch()
- settings_tab.setLayout(settings_layout)
- self.tab_widget.addTab(settings_tab, "Settings")
- self.set_combobox_value(self.service_dropdown, self.service)
- self.set_combobox_value(self.track_list_format_dropdown, self.track_list_format)
- self.set_combobox_value(self.date_format_dropdown, self.date_format)
-
- self.set_combobox_value(self.tidal_api_dropdown, self.tidal_api)
-
- def setup_theme_tab(self):
- theme_tab = QWidget()
- theme_layout = QVBoxLayout()
- theme_layout.setSpacing(8)
- theme_layout.setContentsMargins(8, 15, 15, 15)
-
- grid_layout = QVBoxLayout()
-
- self.color_buttons = {}
-
- first_row_palettes = [
- ("Red", [
- ("#FFCDD2", "100"), ("#EF9A9A", "200"), ("#E57373", "300"), ("#EF5350", "400"), ("#F44336", "500"), ("#E53935", "600"), ("#D32F2F", "700"), ("#C62828", "800"), ("#B71C1C", "900"), ("#FF8A80", "A100"), ("#FF5252", "A200"), ("#FF1744", "A400"), ("#D50000", "A700")
- ]),
- ("Pink", [
- ("#F8BBD0", "100"), ("#F48FB1", "200"), ("#F06292", "300"), ("#EC407A", "400"), ("#E91E63", "500"), ("#D81B60", "600"), ("#C2185B", "700"), ("#AD1457", "800"), ("#880E4F", "900"), ("#FF80AB", "A100"), ("#FF4081", "A200"), ("#F50057", "A400"), ("#C51162", "A700")
- ]),
- ("Purple", [
- ("#E1BEE7", "100"), ("#CE93D8", "200"), ("#BA68C8", "300"), ("#AB47BC", "400"), ("#9C27B0", "500"), ("#8E24AA", "600"), ("#7B1FA2", "700"), ("#6A1B9A", "800"), ("#4A148C", "900"), ("#EA80FC", "A100"), ("#E040FB", "A200"), ("#D500F9", "A400"), ("#AA00FF", "A700")
- ])
- ]
-
- second_row_palettes = [
- ("Deep Purple", [
- ("#D1C4E9", "100"), ("#B39DDB", "200"), ("#9575CD", "300"), ("#7E57C2", "400"), ("#673AB7", "500"), ("#5E35B1", "600"), ("#512DA8", "700"), ("#4527A0", "800"), ("#311B92", "900"), ("#B388FF", "A100"), ("#7C4DFF", "A200"), ("#651FFF", "A400"), ("#6200EA", "A700")
- ]),
- ("Indigo", [
- ("#C5CAE9", "100"), ("#9FA8DA", "200"), ("#7986CB", "300"), ("#5C6BC0", "400"), ("#3F51B5", "500"), ("#3949AB", "600"), ("#303F9F", "700"), ("#283593", "800"), ("#1A237E", "900"), ("#8C9EFF", "A100"), ("#536DFE", "A200"), ("#3D5AFE", "A400"), ("#304FFE", "A700")
- ]),
- ("Blue", [
- ("#BBDEFB", "100"), ("#90CAF9", "200"), ("#64B5F6", "300"), ("#42A5F5", "400"), ("#2196F3", "500"), ("#1E88E5", "600"), ("#1976D2", "700"), ("#1565C0", "800"), ("#0D47A1", "900"), ("#82B1FF", "A100"), ("#448AFF", "A200"), ("#2979FF", "A400"), ("#2962FF", "A700")
- ])
- ]
-
- third_row_palettes = [
- ("Light Blue", [
- ("#B3E5FC", "100"), ("#81D4FA", "200"), ("#4FC3F7", "300"), ("#29B6F6", "400"), ("#03A9F4", "500"), ("#039BE5", "600"), ("#0288D1", "700"), ("#0277BD", "800"), ("#01579B", "900"), ("#80D8FF", "A100"), ("#40C4FF", "A200"), ("#00B0FF", "A400"), ("#0091EA", "A700")
- ]),
- ("Cyan", [
- ("#B2EBF2", "100"), ("#80DEEA", "200"), ("#4DD0E1", "300"), ("#26C6DA", "400"), ("#00BCD4", "500"), ("#00ACC1", "600"), ("#0097A7", "700"), ("#00838F", "800"), ("#006064", "900"), ("#84FFFF", "A100"), ("#18FFFF", "A200"), ("#00E5FF", "A400"), ("#00B8D4", "A700")
- ]),
- ("Teal", [
- ("#B2DFDB", "100"), ("#80CBC4", "200"), ("#4DB6AC", "300"), ("#26A69A", "400"), ("#009688", "500"), ("#00897B", "600"), ("#00796B", "700"), ("#00695C", "800"), ("#004D40", "900"), ("#A7FFEB", "A100"), ("#64FFDA", "A200"), ("#1DE9B6", "A400"), ("#00BFA5", "A700")
- ])
- ]
-
- fourth_row_palettes = [
- ("Green", [
- ("#C8E6C9", "100"), ("#A5D6A7", "200"), ("#81C784", "300"), ("#66BB6A", "400"), ("#4CAF50", "500"), ("#43A047", "600"), ("#388E3C", "700"), ("#2E7D32", "800"), ("#1B5E20", "900"), ("#B9F6CA", "A100"), ("#69F0AE", "A200"), ("#00E676", "A400"), ("#00C853", "A700")
- ]),
- ("Light Green", [
- ("#DCEDC8", "100"), ("#C5E1A5", "200"), ("#AED581", "300"), ("#9CCC65", "400"), ("#8BC34A", "500"), ("#7CB342", "600"), ("#689F38", "700"), ("#558B2F", "800"), ("#33691E", "900"), ("#CCFF90", "A100"), ("#B2FF59", "A200"), ("#76FF03", "A400"), ("#64DD17", "A700")
- ]),
- ("Lime", [
- ("#F0F4C3", "100"), ("#E6EE9C", "200"), ("#DCE775", "300"), ("#D4E157", "400"), ("#CDDC39", "500"), ("#C0CA33", "600"), ("#AFB42B", "700"), ("#9E9D24", "800"), ("#827717", "900"), ("#F4FF81", "A100"), ("#EEFF41", "A200"), ("#C6FF00", "A400"), ("#AEEA00", "A700")
- ])
- ]
-
- fifth_row_palettes = [
- ("Yellow", [
- ("#FFF9C4", "100"), ("#FFF59D", "200"), ("#FFF176", "300"), ("#FFEE58", "400"), ("#FFEB3B", "500"), ("#FDD835", "600"), ("#FBC02D", "700"), ("#F9A825", "800"), ("#F57F17", "900"), ("#FFFF8D", "A100"), ("#FFFF00", "A200"), ("#FFEA00", "A400"), ("#FFD600", "A700")
- ]),
- ("Amber", [
- ("#FFECB3", "100"), ("#FFE082", "200"), ("#FFD54F", "300"), ("#FFCA28", "400"), ("#FFC107", "500"), ("#FFB300", "600"), ("#FFA000", "700"), ("#FF8F00", "800"), ("#FF6F00", "900"), ("#FFE57F", "A100"), ("#FFD740", "A200"), ("#FFC400", "A400"), ("#FFAB00", "A700")
- ]),
- ("Orange", [
- ("#FFE0B2", "100"), ("#FFCC80", "200"), ("#FFB74D", "300"), ("#FFA726", "400"), ("#FF9800", "500"), ("#FB8C00", "600"), ("#F57C00", "700"), ("#EF6C00", "800"), ("#E65100", "900"), ("#FFD180", "A100"), ("#FFAB40", "A200"), ("#FF9100", "A400"), ("#FF6D00", "A700")
- ])
- ]
-
- for row_palettes in [first_row_palettes, second_row_palettes, third_row_palettes, fourth_row_palettes, fifth_row_palettes]:
- row_layout = QHBoxLayout()
- row_layout.setSpacing(15)
-
- for palette_name, colors in row_palettes:
- column_layout = QVBoxLayout()
- column_layout.setSpacing(3)
-
- palette_label = QLabel(palette_name)
- palette_label.setStyleSheet("margin-bottom: 2px;")
- palette_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
- column_layout.addWidget(palette_label)
-
- color_buttons_layout = QHBoxLayout()
- color_buttons_layout.setSpacing(3)
-
- for color_hex, color_name in colors:
- color_btn = QPushButton()
- color_btn.setFixedSize(18, 18)
-
- is_current = color_hex == self.current_theme_color
- border_style = "2px solid #fff" if is_current else "none"
-
- color_btn.setStyleSheet(f"""
- QPushButton {{
- background-color: {color_hex};
- border: {border_style};
- border-radius: 9px;
- }}
- QPushButton:hover {{
- border: 2px solid #fff;
- }}
- QPushButton:pressed {{
- border: 2px solid #fff;
- }}
- """)
- color_btn.setCursor(Qt.CursorShape.PointingHandCursor)
- color_btn.setToolTip(f"{palette_name} {color_name}\n{color_hex}")
- color_btn.clicked.connect(lambda checked, color=color_hex, btn=color_btn: self.change_theme_color(color, btn))
-
- self.color_buttons[color_hex] = color_btn
-
- color_buttons_layout.addWidget(color_btn)
-
- column_layout.addLayout(color_buttons_layout)
- row_layout.addLayout(column_layout)
-
- grid_layout.addLayout(row_layout)
-
- theme_layout.addLayout(grid_layout)
- theme_layout.addStretch()
-
- theme_tab.setLayout(theme_layout)
- self.tab_widget.addTab(theme_tab, "Theme")
-
- def change_theme_color(self, color, clicked_btn=None):
- if hasattr(self, 'color_buttons'):
- for color_hex, btn in self.color_buttons.items():
- if color_hex == self.current_theme_color:
- btn.setStyleSheet(f"""
- QPushButton {{
- background-color: {color_hex};
- border: none;
- border-radius: 9px;
- }}
- QPushButton:hover {{
- border: 2px solid #fff;
- }}
- QPushButton:pressed {{
- border: 2px solid #fff;
- }}
- """)
- break
-
- self.current_theme_color = color
- self.settings.setValue('theme_color', color)
- self.settings.sync()
-
- if clicked_btn:
- clicked_btn.setStyleSheet(f"""
- QPushButton {{
- background-color: {color};
- border: 2px solid #fff;
- border-radius: 9px;
- }}
- QPushButton:hover {{
- border: 2px solid #fff;
- }}
- QPushButton:pressed {{
- border: 2px solid #fff;
- }}
- """)
-
- qdarktheme.setup_theme(
- custom_colors={
- "[dark]": {
- "primary": color,
- }
- }
- )
-
- self.refresh_button_icons()
-
- def refresh_button_icons(self):
- if hasattr(self, 'download_btn'):
- self.download_btn.setIcon(self.get_themed_icon('download.svg'))
- if hasattr(self, 'delete_btn'):
- self.delete_btn.setIcon(self.get_themed_icon('trash.svg'))
-
- if hasattr(self, 'single_download_btn'):
- self.single_download_btn.setIcon(self.get_themed_icon('download.svg'))
- if hasattr(self, 'single_delete_btn'):
- self.single_delete_btn.setIcon(self.get_themed_icon('trash.svg'))
-
- if hasattr(self, 'fix_error_btn'):
- self.fix_error_btn.setIcon(self.get_themed_icon('tool.svg'))
-
- if hasattr(self, 'remove_successful_btn'):
- self.remove_successful_btn.setIcon(self.get_themed_icon('circle-x.svg'))
-
- def setup_about_tab(self):
- about_tab = QWidget()
- about_layout = QVBoxLayout()
- about_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
- about_layout.setSpacing(15)
-
- sections = [
- ("Check for Updates", "Check", "https://github.com/afkarxyz/SpotiFLAC/releases"),
- ("Report an Issue", "Report", "https://github.com/afkarxyz/SpotiFLAC/issues")
- ]
-
- for title, button_text, url in sections:
- section_widget = QWidget()
- section_layout = QVBoxLayout(section_widget)
- section_layout.setSpacing(10)
- section_layout.setContentsMargins(0, 0, 0, 0)
-
- label = QLabel(title)
- label.setStyleSheet("color: palette(text); font-weight: bold;")
- label.setAlignment(Qt.AlignmentFlag.AlignCenter)
- section_layout.addWidget(label)
-
- button = QPushButton(button_text)
- button.setFixedSize(120, 25)
- button.setCursor(Qt.CursorShape.PointingHandCursor)
- button.clicked.connect(lambda _, url=url: QDesktopServices.openUrl(QUrl(url if url.startswith(('http://', 'https://')) else f'https://{url}')))
- section_layout.addWidget(button, alignment=Qt.AlignmentFlag.AlignCenter)
-
- about_layout.addWidget(section_widget)
-
- footer_label = QLabel(f"v{self.current_version} | November 2025")
- about_layout.addWidget(footer_label, alignment=Qt.AlignmentFlag.AlignCenter)
-
- about_tab.setLayout(about_layout)
- self.tab_widget.addTab(about_tab, "About")
-
- def on_service_changed(self, index):
- service = self.service_dropdown.currentData()
- self.service = service
- self.settings.setValue('service', service)
- self.settings.sync()
- self.log_output.append(f"Service changed to: {self.service_dropdown.currentText()}")
- self.update_tidal_api_visibility()
-
- def update_tidal_api_visibility(self):
- is_tidal = self.service_dropdown.currentData() == 'tidal'
- self.api_spacer.setVisible(is_tidal)
- self.tidal_api_label.setVisible(is_tidal)
- self.tidal_api_dropdown.setVisible(is_tidal)
- self.refresh_api_btn.setVisible(is_tidal)
-
- def on_tidal_api_changed(self, index):
- selected_api = self.tidal_api_dropdown.currentData()
- if selected_api:
- self.tidal_api = selected_api
- self.settings.setValue('tidal_api', selected_api)
- self.settings.sync()
- self.log_output.append(f"API Instance changed to: {self.tidal_api_dropdown.currentText()}")
-
- def refresh_tidal_apis(self):
- try:
- self.log_output.append("Fetching available API instances...")
- apis = TidalDownloader.get_available_apis()
-
- while self.tidal_api_dropdown.count() > 1:
- self.tidal_api_dropdown.removeItem(1)
-
- if apis:
- self.log_output.append(f"Found {len(apis)} API instances, loading...")
-
- for api in apis:
- url = api.get('url', '')
- domain = url.replace('https://', '').replace('http://', '')
- label = domain
-
- status_data = {'status': 'CHECKING'}
-
- self.tidal_api_dropdown.addItem(label, url)
- item_index = self.tidal_api_dropdown.count() - 1
- self.tidal_api_dropdown.setItemData(item_index, status_data, Qt.ItemDataRole.UserRole + 1)
-
- self.log_output.append(f"Loaded {len(apis)} API instances, checking status in background...")
-
- self.api_status_checker = APIStatusChecker(apis)
- self.api_status_checker.status_checked.connect(self.on_api_status_checked)
- self.api_status_checker.all_completed.connect(self.on_all_api_status_completed)
- self.api_status_checker.start()
- else:
- self.log_output.append("No APIs found")
- except Exception as e:
- self.log_output.append(f"Error fetching APIs: {str(e)}")
-
- def on_api_status_checked(self, url, status):
- for i in range(self.tidal_api_dropdown.count()):
- if self.tidal_api_dropdown.itemData(i) == url:
- status_data = {'status': status}
- self.tidal_api_dropdown.setItemData(i, status_data, Qt.ItemDataRole.UserRole + 1)
- break
- self.tidal_api_dropdown.update()
-
- def on_all_api_status_completed(self):
- self.log_output.append("API status check completed")
-
- def save_url(self):
- self.settings.setValue('spotify_url', self.spotify_url.text().strip())
- self.settings.sync()
-
- def save_filename_format(self):
- if self.artist_title_radio.isChecked():
- self.filename_format = "artist_title"
- elif self.title_only_radio.isChecked():
- self.filename_format = "title_only"
- else:
- self.filename_format = "title_artist"
- self.settings.setValue('filename_format', self.filename_format)
- self.settings.sync()
-
- def save_track_numbering(self):
- self.use_track_numbers = self.track_number_checkbox.isChecked()
- self.settings.setValue('use_track_numbers', self.use_track_numbers)
- self.settings.sync()
-
- def save_artist_subfolder_setting(self):
- self.use_artist_subfolders = self.artist_subfolder_checkbox.isChecked()
- self.settings.setValue('use_artist_subfolders', self.use_artist_subfolders)
- self.settings.sync()
-
- def save_album_subfolder_setting(self):
- self.use_album_subfolders = self.album_subfolder_checkbox.isChecked()
- self.settings.setValue('use_album_subfolders', self.use_album_subfolders)
- self.settings.sync()
-
- def save_track_list_format(self):
- format_value = self.track_list_format_dropdown.currentData()
- self.track_list_format = format_value
- self.settings.setValue('track_list_format', format_value)
- self.settings.sync()
- if self.tracks:
- self.update_track_list_display()
-
- def save_date_format(self):
- format_value = self.date_format_dropdown.currentData()
- self.date_format = format_value
- self.settings.setValue('date_format', format_value)
- self.settings.sync()
- if self.tracks:
- self.update_track_list_display()
-
- def save_settings(self):
- self.settings.setValue('output_path', self.output_dir.text().strip())
- self.settings.sync()
- self.log_output.append("Settings saved successfully!")
-
- def update_timer(self):
- self.elapsed_time = self.elapsed_time.addSecs(1)
- self.time_label.setText(self.elapsed_time.toString("hh:mm:ss"))
-
- def fetch_tracks(self):
- url = self.spotify_url.text().strip()
-
- if not url:
- self.log_output.append('Warning: Please enter a Spotify URL.')
- return
-
- try:
- if hasattr(self, 'fix_error_btn') and self.fix_error_btn.isVisible():
- self.fix_error_btn.hide()
-
- self.reset_state()
- self.reset_ui()
-
- self.log_output.append('Just a moment. Fetching metadata...')
- self.tab_widget.setCurrentWidget(self.process_tab)
-
- self.metadata_worker = MetadataFetchWorker(url)
- self.metadata_worker.finished.connect(self.on_metadata_fetched)
- self.metadata_worker.error.connect(self.on_metadata_error)
- self.metadata_worker.start()
-
- except Exception as e:
- self.log_output.append(f'Error: Failed to start metadata fetch: {str(e)}')
-
- def on_metadata_fetched(self, metadata):
- try:
- url_info = parse_uri(self.spotify_url.text().strip())
-
- if url_info["type"] == "track":
- self.handle_track_metadata(metadata["track"])
- elif url_info["type"] == "album":
- self.handle_album_metadata(metadata)
- elif url_info["type"] == "playlist":
- self.handle_playlist_metadata(metadata)
- elif url_info["type"] == "artist_discography":
- self.handle_discography_metadata(metadata)
- elif url_info["type"] == "artist":
- self.handle_artist_metadata(metadata)
-
- self.update_button_states()
- self.tab_widget.setCurrentIndex(0)
- except Exception as e:
- self.log_output.append(f'Error: {str(e)}')
-
- def on_metadata_error(self, error_message):
- self.log_output.append(f'Error: {error_message}')
-
- if "Failed to get raw data" in error_message or "Failed to fetch secrets" in error_message or "Failed to get access token" in error_message:
- if not hasattr(self, 'fix_error_btn') or not self.fix_error_btn.isVisible():
- self.show_fix_error_button()
-
- def show_fix_error_button(self):
- if hasattr(self, 'fix_error_btn'):
- self.fix_error_btn.show()
-
- def fix_error_action(self):
- self.fix_error_btn.setEnabled(False)
- self.fix_error_btn.setText("Fixing...")
-
- self.scrape_worker = SecretScrapeWorker()
- self.scrape_worker.progress.connect(lambda msg: self.log_output.append(msg))
- self.scrape_worker.finished.connect(self.on_scrape_finished)
- self.scrape_worker.start()
-
- def on_scrape_finished(self, success, message):
- self.log_output.append(message)
-
- if hasattr(self, 'fix_error_btn'):
- self.fix_error_btn.setEnabled(True)
- self.fix_error_btn.setText("Fix Error")
-
- if success:
- self.fix_error_btn.hide()
-
- if success:
- url = self.spotify_url.text().strip()
- if url:
- self.log_output.append("Retrying fetch...")
- QTimer.singleShot(1000, self.fetch_tracks)
-
- def handle_track_metadata(self, track_data):
- track_id = track_data["external_urls"].split("/")[-1]
-
- track = Track(
- external_urls=track_data["external_urls"],
- title=track_data["name"],
- artists=track_data["artists"],
- album=track_data["album_name"],
- track_number=1,
- duration_ms=track_data.get("duration_ms", 0),
- id=track_id,
- isrc=track_data.get("isrc", ""),
- release_date=track_data.get("release_date", "")
- )
-
- self.tracks = [track]
- self.all_tracks = [track]
- self.is_single_track = True
- self.is_album = self.is_playlist = False
- self.album_or_playlist_name = f"{self.tracks[0].title} - {self.tracks[0].artists}"
-
- metadata = {
- 'title': track_data["name"],
- 'artists': track_data["artists"],
- 'releaseDate': track_data["release_date"],
- 'cover': track_data["images"],
- 'duration_ms': track_data.get("duration_ms", 0)
- }
- self.update_display_after_fetch(metadata)
-
- def handle_album_metadata(self, album_data):
- self.album_or_playlist_name = album_data["album_info"]["name"]
- self.tracks = []
-
- for track in album_data["track_list"]:
- track_id = track["external_urls"].split("/")[-1]
-
- self.tracks.append(Track(
- external_urls=track["external_urls"],
- title=track["name"],
- artists=track["artists"],
- album=self.album_or_playlist_name,
- track_number=track["track_number"],
- duration_ms=track.get("duration_ms", 0),
- id=track_id,
- isrc=track.get("isrc", ""),
- release_date=track.get("release_date", "")
- ))
-
- self.all_tracks = self.tracks.copy()
- self.is_album = True
- self.is_playlist = self.is_single_track = False
-
- metadata = {
- 'title': album_data["album_info"]["name"],
- 'artists': album_data["album_info"]["artists"],
- 'releaseDate': album_data["album_info"]["release_date"],
- 'cover': album_data["album_info"]["images"],
- 'total_tracks': album_data["album_info"]["total_tracks"]
- }
- self.update_display_after_fetch(metadata)
-
- def handle_playlist_metadata(self, playlist_data):
- self.album_or_playlist_name = playlist_data["playlist_info"]["owner"]["name"]
- self.tracks = []
-
- for track in playlist_data["track_list"]:
- track_id = track["external_urls"].split("/")[-1]
-
- self.tracks.append(Track(
- external_urls=track["external_urls"],
- title=track["name"],
- artists=track["artists"],
- album=track["album_name"],
- track_number=track.get("track_number", len(self.tracks) + 1),
- duration_ms=track.get("duration_ms", 0),
- id=track_id,
- isrc=track.get("isrc", ""),
- release_date=track.get("release_date", "")
- ))
-
- self.all_tracks = self.tracks.copy()
- self.is_playlist = True
- self.is_album = self.is_single_track = False
-
- metadata = {
- 'title': playlist_data["playlist_info"]["owner"]["name"],
- 'artists': playlist_data["playlist_info"]["owner"]["display_name"],
- 'cover': playlist_data["playlist_info"]["owner"]["images"],
- 'followers': playlist_data["playlist_info"]["followers"]["total"],
- 'total_tracks': playlist_data["playlist_info"]["tracks"]["total"]
- }
- self.update_display_after_fetch(metadata)
-
- def handle_discography_metadata(self, discography_data):
- artist_info = discography_data["artist_info"]
- self.album_or_playlist_name = f"{artist_info['name']} - Discography ({artist_info['discography_type'].title()})"
- self.tracks = []
-
- for track in discography_data["track_list"]:
- track_id = track["external_urls"].split("/")[-1] if track.get("external_urls") else ""
-
- self.tracks.append(Track(
- external_urls=track.get("external_urls", ""),
- title=track["name"],
- artists=track["artists"],
- album=track["album_name"],
- track_number=track.get("track_number", len(self.tracks) + 1),
- duration_ms=track.get("duration_ms", 0),
- id=track_id,
- isrc=track.get("isrc", ""),
- release_date=track.get("release_date", "")
- ))
-
- self.all_tracks = self.tracks.copy()
- self.is_playlist = True
- self.is_album = self.is_single_track = False
-
- metadata = {
- 'title': f"{artist_info['name']} - Discography",
- 'artists': f"{artist_info['discography_type'].title()} • {artist_info['total_albums']} albums",
- 'cover': artist_info["images"],
- 'followers': artist_info.get("followers", 0),
- 'total_tracks': len(self.tracks),
- 'discography_type': artist_info['discography_type']
- }
- self.update_display_after_fetch(metadata)
-
- def handle_artist_metadata(self, artist_data):
- self.reset_state()
-
- metadata = {
- 'title': artist_data["artist"]["name"],
- 'artists': f"Followers: {artist_data['artist']['followers']:,}",
- 'cover': artist_data["artist"]["images"],
- 'followers': artist_data["artist"]["followers"],
- 'genres': artist_data["artist"].get("genres", [])
- }
-
- self.update_info_widget_artist_only(metadata)
-
- def update_display_after_fetch(self, metadata):
- self.track_list.setVisible(not self.is_single_track)
-
- if not self.is_single_track:
- self.search_widget.show()
- self.update_track_list_display()
- else:
- self.search_widget.hide()
-
- self.update_info_widget(metadata)
-
- def update_info_widget(self, metadata):
- self.title_label.setText(metadata['title'])
-
- if self.is_single_track or self.is_album:
- artists = metadata['artists'] if isinstance(metadata['artists'], list) else metadata['artists'].split(", ")
- label_text = "Artists" if len(artists) > 1 else "Artist"
- artists_text = ", ".join(artists)
- self.artists_label.setText(f"{label_text} {artists_text}")
- else:
- self.artists_label.setText(f"Owner {metadata['artists']}")
-
- if self.is_playlist and 'followers' in metadata:
- self.followers_label.setText(f"Followers {metadata['followers']:,}")
- self.followers_label.show()
- else:
- self.followers_label.hide()
-
- if metadata.get('releaseDate'):
- try:
- release_date = metadata['releaseDate']
- if len(release_date) == 4:
- date_obj = datetime.strptime(release_date, "%Y")
- elif len(release_date) == 7:
- date_obj = datetime.strptime(release_date, "%Y-%m")
- else:
- date_obj = datetime.strptime(release_date, "%Y-%m-%d")
-
- formatted_date = date_obj.strftime("%d-%m-%Y")
- self.release_date_label.setText(f"Released {formatted_date}")
- self.release_date_label.show()
- except ValueError:
- self.release_date_label.setText(f"Released {metadata['releaseDate']}")
- self.release_date_label.show()
- else:
- self.release_date_label.hide()
-
- if self.is_single_track:
- duration = self.format_duration(metadata.get('duration_ms', 0))
- self.type_label.setText(f"Duration {duration}")
- elif self.is_album:
- total_tracks = metadata.get('total_tracks', 0)
- self.type_label.setText(f"Album • {total_tracks} tracks")
- elif self.is_playlist:
- total_tracks = metadata.get('total_tracks', 0)
- if metadata.get('discography_type'):
- discography_type = metadata['discography_type'].title()
- self.type_label.setText(f"Discography ({discography_type}) • {total_tracks} tracks")
- else:
- self.type_label.setText(f"Playlist • {total_tracks} tracks")
-
- self.network_manager.get(QNetworkRequest(QUrl(metadata['cover'])))
-
- self.info_widget.show()
-
- def update_info_widget_artist_only(self, metadata):
- self.title_label.setText(metadata['title'])
- self.artists_label.setText(f"Followers {metadata['followers']:,}")
-
- if metadata.get('genres'):
- genres_text = ", ".join(metadata['genres'][:3])
- if len(metadata['genres']) > 3:
- genres_text += f" (+{len(metadata['genres']) - 3} more)"
- self.followers_label.setText(f"Genres {genres_text}")
- self.followers_label.show()
- else:
- self.followers_label.hide()
-
- self.release_date_label.hide()
- self.type_label.setText("Artist Profile • No tracks available for download")
-
- self.network_manager.get(QNetworkRequest(QUrl(metadata['cover'])))
-
- self.track_list.hide()
- self.search_widget.hide()
- self.hide_track_buttons()
-
- self.info_widget.show()
-
- def reset_info_widget(self):
- self.title_label.clear()
- self.artists_label.clear()
- self.followers_label.clear()
- self.release_date_label.clear()
- self.type_label.clear()
- self.cover_label.clear()
- self.info_widget.hide()
-
- def on_cover_loaded(self, reply):
- if reply.error() == QNetworkReply.NetworkError.NoError:
- data = reply.readAll()
- pixmap = QPixmap()
- pixmap.loadFromData(data)
- self.cover_label.setPixmap(pixmap)
-
- def update_button_states(self):
- if self.is_single_track:
- for btn in [self.download_btn, self.delete_btn]:
- btn.hide()
-
- self.single_track_container.show()
-
- self.single_download_btn.setEnabled(True)
- self.single_delete_btn.setEnabled(True)
-
- else:
- self.single_track_container.hide()
-
- self.download_btn.show()
- self.delete_btn.show()
-
- self.download_btn.setEnabled(True)
- self.delete_btn.setEnabled(True)
-
- def hide_track_buttons(self):
- buttons = [
- self.download_btn,
- self.delete_btn
- ]
- for btn in buttons:
- btn.hide()
-
- if hasattr(self, 'single_track_container'):
- self.single_track_container.hide()
-
- def download_tracks_action(self):
- if self.is_single_track:
- self.start_download([0])
- else:
- selected_items = self.track_list.selectedItems()
-
- if not selected_items:
- reply = QMessageBox.question(
- self,
- 'Confirm Download All',
- f'No tracks selected. Download all {len(self.tracks)} tracks?',
- QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
- QMessageBox.StandardButton.No
- )
-
- if reply == QMessageBox.StandardButton.Yes:
- self.start_download(range(len(self.tracks)))
- else:
- selected_indices = [self.track_list.row(item) for item in selected_items]
- self.start_download(selected_indices)
-
- def start_download(self, indices):
- self.log_output.clear()
- raw_outpath = self.output_dir.text().strip()
- outpath = os.path.normpath(raw_outpath)
- if not os.path.exists(outpath):
- self.log_output.append('Warning: Invalid output directory.')
- return
-
- tracks_to_download = self.tracks if self.is_single_track else [self.tracks[i] for i in indices]
-
- if self.is_album or self.is_playlist:
- name = self.album_or_playlist_name.strip()
- folder_name = re.sub(r'[<>:"/\\|?*]', '_', name)
- folder_name = folder_name.rstrip('. ')
- outpath = os.path.join(outpath, folder_name)
- os.makedirs(outpath, exist_ok=True)
-
- try:
- self.start_download_worker(tracks_to_download, outpath)
- except Exception as e:
- self.log_output.append(f"Error: An error occurred while starting the download: {str(e)}")
-
- def start_download_worker(self, tracks_to_download, outpath):
- service = self.service_dropdown.currentData()
-
- tidal_api_url = None
- if service == "tidal":
- selected_api = self.tidal_api_dropdown.currentData()
- if selected_api == "auto":
- tidal_api_url = "auto"
- self.log_output.append("Using auto fallback mode (will try multiple APIs)")
- else:
- tidal_api_url = selected_api
- self.log_output.append(f"Using API: {selected_api}")
-
- self.worker = DownloadWorker(
- tracks_to_download,
- outpath,
- self.is_single_track,
- self.is_album,
- self.is_playlist,
- self.album_or_playlist_name,
- self.filename_format,
- self.use_track_numbers,
- self.use_artist_subfolders,
- self.use_album_subfolders,
- service,
- tidal_api_url
- )
- self.worker.finished.connect(lambda success, message, failed_tracks, successful_tracks, skipped_tracks: self.on_download_finished(success, message, failed_tracks, successful_tracks, skipped_tracks))
- self.worker.progress.connect(self.update_progress)
- self.worker.start()
- self.start_timer()
- self.update_ui_for_download_start()
-
- def update_ui_for_download_start(self):
- self.download_btn.setEnabled(False)
-
- if hasattr(self, 'single_download_btn'):
- self.single_download_btn.setEnabled(False)
- if hasattr(self, 'single_delete_btn'):
- self.single_delete_btn.setEnabled(False)
-
- self.stop_btn.show()
- self.pause_resume_btn.show()
- self.remove_successful_btn.hide()
- self.progress_bar.show()
- self.progress_bar.setValue(0)
-
- self.tab_widget.setCurrentWidget(self.process_tab)
-
- def update_progress(self, message, percentage):
- self.log_output.append(message)
- self.log_output.moveCursor(QTextCursor.MoveOperation.End)
- if percentage > 0:
- self.progress_bar.setValue(percentage)
-
- def stop_download(self):
- if hasattr(self, 'worker'):
- self.worker.stop()
- self.stop_timer()
- self.on_download_finished(True, "Download stopped by user.", [], [], [])
-
- def on_download_finished(self, success, message, failed_tracks, successful_tracks=None, skipped_tracks=None):
- self.progress_bar.hide()
- self.stop_btn.hide()
- self.pause_resume_btn.hide()
- self.pause_resume_btn.setText('Pause')
- self.stop_timer()
-
- if successful_tracks is not None:
- self.successful_downloads = successful_tracks
- if skipped_tracks is not None:
- self.skipped_downloads = skipped_tracks
-
- if (hasattr(self, 'successful_downloads') and self.successful_downloads) or (hasattr(self, 'skipped_downloads') and self.skipped_downloads):
- self.remove_successful_btn.show()
- else:
- self.remove_successful_btn.hide()
-
- self.download_btn.setEnabled(True)
-
- if hasattr(self, 'single_download_btn'):
- self.single_download_btn.setEnabled(True)
- if hasattr(self, 'single_delete_btn'):
- self.single_delete_btn.setEnabled(True)
-
- if success:
- self.log_output.append(f"\nStatus: {message}")
- if failed_tracks:
- self.log_output.append("\nFailed downloads:")
- for title, artists, error in failed_tracks:
- self.log_output.append(f"• {title} - {artists}")
- self.log_output.append(f" Error: {error}\n")
- else:
- self.log_output.append(f"Error: {message}")
-
- self.tab_widget.setCurrentWidget(self.process_tab)
-
- def toggle_pause_resume(self):
- if hasattr(self, 'worker'):
- if self.worker.is_paused:
- self.worker.resume()
- self.pause_resume_btn.setText('Pause')
- self.timer.start(1000)
- else:
- self.worker.pause()
- self.pause_resume_btn.setText('Resume')
-
- def remove_successful_downloads(self):
- successful_tracks = getattr(self, 'successful_downloads', [])
- skipped_tracks = getattr(self, 'skipped_downloads', [])
-
- if not successful_tracks and not skipped_tracks:
- self.log_output.append("No downloaded or skipped tracks to remove.")
- return
-
- tracks_to_remove = []
-
- for track in self.tracks:
- for successful_track in successful_tracks:
- if (track.title == successful_track.title and
- track.artists == successful_track.artists and
- track.album == successful_track.album):
- tracks_to_remove.append(track)
- break
-
- for track in self.tracks:
- for skipped_track in skipped_tracks:
- if (track.title == skipped_track.title and
- track.artists == skipped_track.artists and
- track.album == skipped_track.album):
- if track not in tracks_to_remove:
- tracks_to_remove.append(track)
- break
-
- if tracks_to_remove:
- for track in tracks_to_remove:
- if track in self.tracks:
- self.tracks.remove(track)
- if track in self.all_tracks:
- self.all_tracks.remove(track)
-
- self.update_track_list_display()
- successful_count = len([t for t in tracks_to_remove if t in successful_tracks])
- skipped_count = len([t for t in tracks_to_remove if t in skipped_tracks])
-
- message = f"Removed {len(tracks_to_remove)} tracks from the list"
- if successful_count > 0:
- message += f" ({successful_count} downloaded"
- if skipped_count > 0:
- message += f", {skipped_count} already existed" if successful_count > 0 else f" ({skipped_count} already existed"
- if successful_count > 0 or skipped_count > 0:
- message += ")"
-
- self.log_output.append(message + ".")
- self.tab_widget.setCurrentIndex(0)
- else:
- self.log_output.append("No matching tracks found in the current list.")
-
- self.remove_successful_btn.hide()
-
- def delete_tracks(self):
- if self.is_single_track:
- self.reset_state()
- self.reset_ui()
- else:
- selected_items = self.track_list.selectedItems()
-
- if not selected_items:
- reply = QMessageBox.question(
- self,
- 'Confirm Delete All',
- f'No tracks selected. Delete all {len(self.tracks)} tracks?',
- QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
- QMessageBox.StandardButton.No
- )
-
- if reply == QMessageBox.StandardButton.Yes:
- self.reset_state()
- self.reset_ui()
- else:
- selected_indices = [self.track_list.row(item) for item in selected_items]
- tracks_to_remove = [self.tracks[i] for i in selected_indices]
-
- for track in tracks_to_remove:
- if track in self.tracks:
- self.tracks.remove(track)
- if track in self.all_tracks:
- self.all_tracks.remove(track)
-
- self.update_track_list_display()
- self.tab_widget.setCurrentIndex(0)
-
- def start_timer(self):
- self.elapsed_time = QTime(0, 0, 0)
- self.time_label.setText("00:00:00")
- self.time_label.show()
- self.timer.start(1000)
-
- def stop_timer(self):
- self.timer.stop()
- self.time_label.hide()
-
- def closeEvent(self, event):
- if hasattr(self, 'timer'):
- self.timer.stop()
-
- if hasattr(self, 'service_dropdown'):
- for attr_name in ['tidal_status_checker', 'deezer_status_checker']:
- if hasattr(self.service_dropdown, attr_name):
- checker = getattr(self.service_dropdown, attr_name)
- if checker.isRunning():
- checker.quit()
- checker.wait()
-
- if hasattr(self, 'worker') and self.worker and self.worker.isRunning():
- self.worker.stop()
- self.worker.quit()
- self.worker.wait()
-
- event.accept()
-
-if __name__ == '__main__':
- try:
- if sys.platform == "win32":
- import io
- sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
- sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace')
- except Exception as e:
- pass
-
- app = QApplication(sys.argv)
-
- settings = QSettings('SpotiFLAC', 'Settings')
- theme_color = settings.value('theme_color', '#2196F3')
-
- qdarktheme.setup_theme(
- custom_colors={
- "[dark]": {
- "primary": theme_color,
- }
- }
- )
- ex = SpotiFLACGUI()
- ex.show()
- sys.exit(app.exec())
\ No newline at end of file
diff --git a/app.go b/app.go
new file mode 100644
index 0000000..d19c7ad
--- /dev/null
+++ b/app.go
@@ -0,0 +1,160 @@
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "spotiflac/backend"
+ "time"
+)
+
+// App struct
+type App struct {
+ ctx context.Context
+}
+
+// NewApp creates a new App application struct
+func NewApp() *App {
+ return &App{}
+}
+
+// startup is called when the app starts. The context is saved
+// so we can call the runtime methods
+func (a *App) startup(ctx context.Context) {
+ a.ctx = ctx
+}
+
+// SpotifyMetadataRequest represents the request structure for fetching Spotify metadata
+type SpotifyMetadataRequest struct {
+ URL string `json:"url"`
+ Batch bool `json:"batch"`
+ Delay float64 `json:"delay"`
+ Timeout float64 `json:"timeout"`
+}
+
+// DownloadRequest represents the request structure for downloading tracks
+type DownloadRequest struct {
+ ISRC string `json:"isrc"`
+ Service string `json:"service"`
+ Query string `json:"query,omitempty"`
+ ApiURL string `json:"api_url,omitempty"`
+ OutputDir string `json:"output_dir,omitempty"`
+ AudioFormat string `json:"audio_format,omitempty"`
+}
+
+// DownloadResponse represents the response structure for download operations
+type DownloadResponse struct {
+ Success bool `json:"success"`
+ Message string `json:"message"`
+ File string `json:"file,omitempty"`
+ Error string `json:"error,omitempty"`
+}
+
+// GetSpotifyMetadata fetches metadata from Spotify
+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()
+
+ data, err := backend.GetFilteredSpotifyData(ctx, req.URL, req.Batch, time.Duration(req.Delay*float64(time.Second)))
+ 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
+}
+
+// DownloadTrack downloads a track by ISRC
+func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
+ if req.ISRC == "" {
+ return DownloadResponse{
+ Success: false,
+ Error: "ISRC is required",
+ }, fmt.Errorf("ISRC is required")
+ }
+
+ if req.Service == "" {
+ req.Service = "deezer"
+ }
+
+ if req.OutputDir == "" {
+ req.OutputDir = "."
+ }
+
+ if req.AudioFormat == "" {
+ req.AudioFormat = "LOSSLESS"
+ }
+
+ var err error
+ var filename string
+
+ if req.Service == "tidal" {
+ searchQuery := req.Query
+ if searchQuery == "" {
+ searchQuery = req.ISRC
+ }
+
+ if req.ApiURL == "" || req.ApiURL == "auto" {
+ downloader := backend.NewTidalDownloader("")
+ filename, err = downloader.DownloadWithFallback(searchQuery, req.ISRC, req.OutputDir, req.AudioFormat)
+ } else {
+ downloader := backend.NewTidalDownloader(req.ApiURL)
+ filename, err = downloader.Download(searchQuery, req.ISRC, req.OutputDir, req.AudioFormat)
+ }
+ } else {
+ downloader := backend.NewDeezerDownloader()
+ err = downloader.DownloadByISRC(req.ISRC, req.OutputDir)
+ if err == nil {
+ filename = "Downloaded via Deezer"
+ }
+ }
+
+ if err != nil {
+ return DownloadResponse{
+ Success: false,
+ Error: fmt.Sprintf("Download failed: %v", err),
+ }, err
+ }
+
+ return DownloadResponse{
+ Success: true,
+ Message: "Download completed successfully",
+ File: filename,
+ }, nil
+}
+
+// OpenFolder opens a folder in the file explorer
+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
+}
+
+// GetDefaults returns the default configuration
+func (a *App) GetDefaults() map[string]string {
+ return map[string]string{
+ "downloadPath": backend.GetDefaultMusicPath(),
+ }
+}
diff --git a/backend/config.go b/backend/config.go
new file mode 100644
index 0000000..5e2f793
--- /dev/null
+++ b/backend/config.go
@@ -0,0 +1,18 @@
+package backend
+
+import (
+ "os"
+ "path/filepath"
+)
+
+func GetDefaultMusicPath() string {
+ // Get user's home directory
+ homeDir, err := os.UserHomeDir()
+ if err != nil {
+ // Fallback to Public Music if can't get home dir
+ return "C:\\Users\\Public\\Music"
+ }
+
+ // Return path to user's Music folder
+ return filepath.Join(homeDir, "Music")
+}
diff --git a/backend/deezer.go b/backend/deezer.go
new file mode 100644
index 0000000..ceab07a
--- /dev/null
+++ b/backend/deezer.go
@@ -0,0 +1,234 @@
+package backend
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strings"
+ "time"
+)
+
+type DeezerDownloader struct {
+ client *http.Client
+}
+
+type DeezerTrack struct {
+ ID int64 `json:"id"`
+ Title string `json:"title"`
+ TitleShort string `json:"title_short"`
+ Duration int `json:"duration"`
+ TrackPos int `json:"track_position"`
+ DiskNumber int `json:"disk_number"`
+ ISRC string `json:"isrc"`
+ ReleaseDate string `json:"release_date"`
+ Artist struct {
+ Name string `json:"name"`
+ ID int64 `json:"id"`
+ } `json:"artist"`
+ Album struct {
+ Title string `json:"title"`
+ ID int64 `json:"id"`
+ CoverXL string `json:"cover_xl"`
+ CoverBig string `json:"cover_big"`
+ } `json:"album"`
+ Contributors []struct {
+ Name string `json:"name"`
+ Role string `json:"role"`
+ } `json:"contributors"`
+}
+
+type DeezMateResponse struct {
+ Success bool `json:"success"`
+ Links struct {
+ FLAC string `json:"flac"`
+ } `json:"links"`
+}
+
+func NewDeezerDownloader() *DeezerDownloader {
+ return &DeezerDownloader{
+ client: &http.Client{
+ Timeout: 60 * time.Second,
+ },
+ }
+}
+
+func (d *DeezerDownloader) GetTrackByISRC(isrc string) (*DeezerTrack, error) {
+ url := fmt.Sprintf("https://api.deezer.com/2.0/track/isrc:%s", isrc)
+
+ resp, err := d.client.Get(url)
+ if err != nil {
+ return nil, fmt.Errorf("failed to fetch track: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != 200 {
+ return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
+ }
+
+ var track DeezerTrack
+ if err := json.NewDecoder(resp.Body).Decode(&track); err != nil {
+ return nil, fmt.Errorf("failed to decode response: %w", err)
+ }
+
+ if track.ID == 0 {
+ return nil, fmt.Errorf("track not found for ISRC: %s", isrc)
+ }
+
+ return &track, nil
+}
+
+func (d *DeezerDownloader) GetDownloadURL(trackID int64) (string, error) {
+ url := fmt.Sprintf("https://api.deezmate.com/dl/%d", trackID)
+
+ resp, err := d.client.Get(url)
+ if err != nil {
+ return "", fmt.Errorf("failed to get download URL: %w", err)
+ }
+ defer resp.Body.Close()
+
+ var apiResp DeezMateResponse
+ if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
+ return "", fmt.Errorf("failed to decode API response: %w", err)
+ }
+
+ if !apiResp.Success || apiResp.Links.FLAC == "" {
+ return "", fmt.Errorf("no FLAC download link available")
+ }
+
+ return apiResp.Links.FLAC, nil
+}
+
+func (d *DeezerDownloader) DownloadFile(url, filepath string) error {
+ resp, err := d.client.Get(url)
+ 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()
+
+ _, err = io.Copy(out, resp.Body)
+ if err != nil {
+ return fmt.Errorf("failed to write file: %w", err)
+ }
+
+ return nil
+}
+
+func (d *DeezerDownloader) DownloadCoverArt(coverURL, filepath string) error {
+ if coverURL == "" {
+ return fmt.Errorf("no cover URL provided")
+ }
+
+ resp, err := d.client.Get(coverURL)
+ 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 sanitizeFilename(name string) string {
+ re := regexp.MustCompile(`[<>:"/\\|?*]`)
+ sanitized := re.ReplaceAllString(name, "_")
+ sanitized = strings.TrimSpace(sanitized)
+ if sanitized == "" {
+ return "Unknown"
+ }
+ return sanitized
+}
+
+func (d *DeezerDownloader) DownloadByISRC(isrc, outputDir string) error {
+ fmt.Printf("Fetching track info for ISRC: %s\n", isrc)
+
+ track, err := d.GetTrackByISRC(isrc)
+ if err != nil {
+ return err
+ }
+
+ artists := track.Artist.Name
+ if len(track.Contributors) > 0 {
+ var mainArtists []string
+ for _, contrib := range track.Contributors {
+ if contrib.Role == "Main" {
+ mainArtists = append(mainArtists, contrib.Name)
+ }
+ }
+ if len(mainArtists) > 0 {
+ artists = strings.Join(mainArtists, ", ")
+ }
+ }
+
+ fmt.Printf("Found track: %s - %s\n", artists, track.Title)
+ fmt.Printf("Album: %s\n", track.Album.Title)
+
+ downloadURL, err := d.GetDownloadURL(track.ID)
+ if err != nil {
+ return err
+ }
+
+ safeArtist := sanitizeFilename(artists)
+ safeTitle := sanitizeFilename(track.Title)
+ filename := fmt.Sprintf("%s - %s.flac", safeArtist, safeTitle)
+ filepath := filepath.Join(outputDir, filename)
+
+ fmt.Println("Downloading FLAC file...")
+ if err := d.DownloadFile(downloadURL, filepath); err != nil {
+ return err
+ }
+
+ fmt.Printf("Downloaded: %s\n", filepath)
+
+ coverPath := ""
+ if track.Album.CoverXL != "" {
+ coverPath = filepath + ".cover.jpg"
+ fmt.Println("Downloading cover art...")
+ if err := d.DownloadCoverArt(track.Album.CoverXL, coverPath); err != nil {
+ fmt.Printf("Warning: Failed to download cover art: %v\n", err)
+ } else {
+ defer os.Remove(coverPath)
+ }
+ }
+
+ fmt.Println("Embedding metadata and cover art...")
+ metadata := Metadata{
+ Title: track.Title,
+ Artist: artists,
+ Album: track.Album.Title,
+ Date: track.ReleaseDate,
+ TrackNumber: track.TrackPos,
+ DiscNumber: track.DiskNumber,
+ ISRC: track.ISRC,
+ }
+
+ if err := EmbedMetadata(filepath, metadata, coverPath); err != nil {
+ return fmt.Errorf("failed to embed metadata: %w", err)
+ }
+
+ fmt.Println("Metadata embedded successfully!")
+ return nil
+}
diff --git a/backend/folder.go b/backend/folder.go
new file mode 100644
index 0000000..17fffe4
--- /dev/null
+++ b/backend/folder.go
@@ -0,0 +1,23 @@
+package backend
+
+import (
+ "os/exec"
+ "runtime"
+)
+
+func OpenFolderInExplorer(path string) error {
+ var cmd *exec.Cmd
+
+ switch runtime.GOOS {
+ case "windows":
+ cmd = exec.Command("explorer", path)
+ case "darwin": // macOS
+ cmd = exec.Command("open", path)
+ case "linux":
+ cmd = exec.Command("xdg-open", path)
+ default:
+ cmd = exec.Command("xdg-open", path)
+ }
+
+ return cmd.Start()
+}
diff --git a/backend/metadata.go b/backend/metadata.go
new file mode 100644
index 0000000..7bb8bed
--- /dev/null
+++ b/backend/metadata.go
@@ -0,0 +1,113 @@
+package backend
+
+import (
+ "fmt"
+ "os"
+ "strconv"
+
+ "github.com/go-flac/flacpicture"
+ "github.com/go-flac/flacvorbis"
+ "github.com/go-flac/go-flac"
+)
+
+type Metadata struct {
+ Title string
+ Artist string
+ Album string
+ Date string
+ TrackNumber int
+ DiscNumber int
+ ISRC string
+}
+
+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()
+
+ if metadata.Title != "" {
+ _ = cmt.Add(flacvorbis.FIELD_TITLE, metadata.Title)
+ }
+ if metadata.Artist != "" {
+ _ = cmt.Add(flacvorbis.FIELD_ARTIST, metadata.Artist)
+ }
+ if metadata.Album != "" {
+ _ = cmt.Add(flacvorbis.FIELD_ALBUM, metadata.Album)
+ }
+ 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.DiscNumber > 0 {
+ _ = cmt.Add("DISCNUMBER", strconv.Itoa(metadata.DiscNumber))
+ }
+ if metadata.ISRC != "" {
+ _ = cmt.Add(flacvorbis.FIELD_ISRC, metadata.ISRC)
+ }
+
+ 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
+}
diff --git a/backend/spotify_metadata.go b/backend/spotify_metadata.go
new file mode 100644
index 0000000..3c11ada
--- /dev/null
+++ b/backend/spotify_metadata.go
@@ -0,0 +1,1155 @@
+package backend
+
+import (
+ "context"
+ "crypto/hmac"
+ "crypto/sha1"
+ "encoding/base32"
+ "encoding/binary"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "math/rand"
+ "net/http"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+)
+
+const (
+ spotifyTokenURL = "https://open.spotify.com/api/token"
+ playlistBaseURL = "https://api.spotify.com/v1/playlists/%s"
+ albumBaseURL = "https://api.spotify.com/v1/albums/%s"
+ trackBaseURL = "https://api.spotify.com/v1/tracks/%s"
+ artistBaseURL = "https://api.spotify.com/v1/artists/%s"
+ artistAlbumsBaseURL = "https://api.spotify.com/v1/artists/%s/albums"
+ secretBytesRemotePath = "https://raw.githubusercontent.com/afkarxyz/secretBytes/refs/heads/main/secrets/secretBytes.json"
+)
+
+var (
+ errInvalidSpotifyURL = errors.New("invalid or unsupported Spotify URL")
+)
+
+// SpotifyMetadataClient mirrors the behaviour of Doc/getMetadata.py and interacts with Spotify's web API.
+type SpotifyMetadataClient struct {
+ httpClient *http.Client
+ rng *rand.Rand
+ rngMu sync.Mutex
+ userAgent string
+}
+
+// NewSpotifyMetadataClient creates a ready-to-use client with sane defaults.
+func NewSpotifyMetadataClient() *SpotifyMetadataClient {
+ src := rand.NewSource(time.Now().UnixNano())
+ c := &SpotifyMetadataClient{
+ httpClient: &http.Client{Timeout: 15 * time.Second},
+ rng: rand.New(src),
+ }
+ c.userAgent = c.randomUserAgent()
+ return c
+}
+
+// TrackMetadata mirrors the filtered track payload returned by the Python script.
+type TrackMetadata struct {
+ Artists string `json:"artists"`
+ Name string `json:"name"`
+ AlbumName string `json:"album_name"`
+ DurationMS int `json:"duration_ms"`
+ Images string `json:"images"`
+ ReleaseDate string `json:"release_date"`
+ TrackNumber int `json:"track_number"`
+ ExternalURL string `json:"external_urls"`
+ ISRC string `json:"isrc"`
+}
+
+// AlbumTrackMetadata holds per-track info for album / playlist formatting.
+type AlbumTrackMetadata struct {
+ Artists string `json:"artists"`
+ Name string `json:"name"`
+ AlbumName string `json:"album_name"`
+ DurationMS int `json:"duration_ms"`
+ Images string `json:"images"`
+ ReleaseDate string `json:"release_date"`
+ TrackNumber int `json:"track_number"`
+ ExternalURL string `json:"external_urls"`
+ ISRC string `json:"isrc"`
+ AlbumType string `json:"album_type,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"`
+ Batch string `json:"batch,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"`
+ 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"`
+ ExternalURL string `json:"external_urls"`
+ DiscographyType string `json:"discography_type"`
+ TotalAlbums int `json:"total_albums"`
+ 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 secretEntry struct {
+ Version int `json:"version"`
+ Secret []int `json:"secret"`
+}
+
+type serverTimeResponse struct {
+ ServerTime int64 `json:"serverTime"`
+}
+
+type accessTokenResponse struct {
+ AccessToken string `json:"accessToken"`
+}
+
+type image struct {
+ URL string `json:"url"`
+}
+
+type externalURL struct {
+ Spotify string `json:"spotify"`
+}
+
+type externalID struct {
+ ISRC string `json:"isrc"`
+}
+
+type artist struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+}
+
+type albumSimplified struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ AlbumType string `json:"album_type"`
+ ReleaseDate string `json:"release_date"`
+ TotalTracks int `json:"total_tracks"`
+ Images []image `json:"images"`
+ ExternalURL externalURL `json:"external_urls"`
+ Artists []artist `json:"artists"`
+}
+
+type trackSimplified struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ DurationMS int `json:"duration_ms"`
+ TrackNumber int `json:"track_number"`
+ ExternalURL externalURL `json:"external_urls"`
+ Artists []artist `json:"artists"`
+}
+
+type trackFull struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ DurationMS int `json:"duration_ms"`
+ TrackNumber int `json:"track_number"`
+ ExternalURL externalURL `json:"external_urls"`
+ ExternalID externalID `json:"external_ids"`
+ Album albumSimplified `json:"album"`
+ Artists []artist `json:"artists"`
+}
+
+type playlistTrackItem struct {
+ Track *trackFull `json:"track"`
+}
+
+type playlistResponse struct {
+ Name string `json:"name"`
+ Images []image `json:"images"`
+ Owner struct {
+ DisplayName string `json:"display_name"`
+ } `json:"owner"`
+ Followers struct {
+ Total int `json:"total"`
+ } `json:"followers"`
+ Tracks struct {
+ Items []playlistTrackItem `json:"items"`
+ Next string `json:"next"`
+ Total int `json:"total"`
+ } `json:"tracks"`
+}
+
+type albumResponse struct {
+ Name string `json:"name"`
+ ReleaseDate string `json:"release_date"`
+ TotalTracks int `json:"total_tracks"`
+ Images []image `json:"images"`
+ Artists []artist `json:"artists"`
+ Tracks struct {
+ Items []trackSimplified `json:"items"`
+ Next string `json:"next"`
+ } `json:"tracks"`
+}
+
+type artistResponse struct {
+ Name string `json:"name"`
+ Followers struct {
+ Total int `json:"total"`
+ } `json:"followers"`
+ Genres []string `json:"genres"`
+ Images []image `json:"images"`
+ ExternalURL externalURL `json:"external_urls"`
+ Popularity int `json:"popularity"`
+}
+
+type artistAlbumsResponse struct {
+ Items []albumSimplified `json:"items"`
+ Next string `json:"next"`
+}
+
+type playlistRaw struct {
+ Data playlistResponse
+ BatchEnabled bool
+ BatchCount int
+}
+
+type albumRaw struct {
+ Data albumResponse
+ Token string
+ BatchEnabled bool
+ BatchCount int
+}
+
+type discographyRaw struct {
+ Artist artistResponse
+ Albums []albumSimplified
+ Token string
+ Discography string
+ BatchEnabled bool
+ BatchCount int
+}
+
+// GetFilteredSpotifyData is a convenience wrapper that mirrors the Python module's entry point.
+func GetFilteredSpotifyData(ctx context.Context, spotifyURL string, batch bool, delay time.Duration) (interface{}, error) {
+ client := NewSpotifyMetadataClient()
+ return client.GetFilteredData(ctx, spotifyURL, batch, delay)
+}
+
+// GetFilteredData fetches, normalises, and formats Spotify payloads for the given URL.
+func (c *SpotifyMetadataClient) GetFilteredData(ctx context.Context, spotifyURL string, batch bool, delay time.Duration) (interface{}, error) {
+ parsed, err := parseSpotifyURI(spotifyURL)
+ if err != nil {
+ return nil, err
+ }
+
+ token, err := c.getAccessToken(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ raw, err := c.getRawSpotifyData(ctx, parsed, token, batch, delay)
+ if err != nil {
+ return nil, err
+ }
+
+ return c.processSpotifyData(ctx, raw, parsed.Type)
+}
+
+func (c *SpotifyMetadataClient) getRawSpotifyData(ctx context.Context, parsed spotifyURI, token string, batch bool, delay time.Duration) (interface{}, error) {
+ switch parsed.Type {
+ case "playlist":
+ return c.fetchPlaylist(ctx, parsed.ID, token, batch, delay)
+ case "album":
+ return c.fetchAlbum(ctx, parsed.ID, token, batch, delay)
+ case "track":
+ return c.fetchTrack(ctx, parsed.ID, token)
+ case "artist_discography":
+ return c.fetchArtistDiscography(ctx, parsed, token, batch, delay)
+ case "artist":
+ return c.fetchArtist(ctx, parsed.ID, token)
+ default:
+ return nil, fmt.Errorf("unsupported Spotify type: %s", parsed.Type)
+ }
+}
+
+func (c *SpotifyMetadataClient) processSpotifyData(ctx context.Context, raw interface{}, dataType string) (interface{}, error) {
+ switch payload := raw.(type) {
+ case *playlistRaw:
+ return c.formatPlaylistData(payload), nil
+ case *albumRaw:
+ return c.formatAlbumData(ctx, payload)
+ case *trackFull:
+ trackPayload := formatTrackData(payload)
+ return trackPayload, nil
+ case *discographyRaw:
+ return c.formatArtistDiscographyData(ctx, payload)
+ case *artistResponse:
+ formatted := formatArtistData(payload)
+ return formatted, nil
+ default:
+ return nil, errors.New("unknown raw payload type")
+ }
+}
+
+func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, token string, batch bool, delay time.Duration) (*playlistRaw, error) {
+ var data playlistResponse
+ if err := c.getJSON(ctx, fmt.Sprintf(playlistBaseURL, playlistID), token, &data); err != nil {
+ return nil, err
+ }
+
+ tracksURL := fmt.Sprintf("https://api.spotify.com/v1/playlists/%s/tracks?limit=100", playlistID)
+ var items []playlistTrackItem
+ batchDelay := time.Duration(0)
+ if batch {
+ batchDelay = delay
+ }
+ batches, err := fetchPaging(ctx, c, tracksURL, token, batchDelay, &items)
+ if err != nil {
+ return nil, err
+ }
+ if len(items) > 0 {
+ data.Tracks.Items = items
+ }
+
+ return &playlistRaw{
+ Data: data,
+ BatchEnabled: batch,
+ BatchCount: batches,
+ }, nil
+}
+
+func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token string, batch bool, delay time.Duration) (*albumRaw, error) {
+ var data albumResponse
+ if err := c.getJSON(ctx, fmt.Sprintf(albumBaseURL, albumID), token, &data); err != nil {
+ return nil, err
+ }
+
+ tracksURL := fmt.Sprintf("%s/tracks?limit=50", fmt.Sprintf(albumBaseURL, albumID))
+ var items []trackSimplified
+ batchDelay := time.Duration(0)
+ if batch {
+ batchDelay = delay
+ }
+ batches, err := fetchPaging(ctx, c, tracksURL, token, batchDelay, &items)
+ if err != nil {
+ return nil, err
+ }
+ if len(items) > 0 {
+ data.Tracks.Items = items
+ }
+
+ return &albumRaw{
+ Data: data,
+ Token: token,
+ BatchEnabled: batch,
+ BatchCount: batches,
+ }, nil
+}
+
+func (c *SpotifyMetadataClient) fetchTrack(ctx context.Context, trackID, token string) (*trackFull, error) {
+ var data trackFull
+ if err := c.getJSON(ctx, fmt.Sprintf(trackBaseURL, trackID), token, &data); err != nil {
+ return nil, err
+ }
+ return &data, nil
+}
+
+func (c *SpotifyMetadataClient) fetchArtistDiscography(ctx context.Context, parsed spotifyURI, token string, batch bool, delay time.Duration) (*discographyRaw, error) {
+ var artistData artistResponse
+ if err := c.getJSON(ctx, fmt.Sprintf(artistBaseURL, parsed.ID), token, &artistData); err != nil {
+ return nil, err
+ }
+
+ includeGroups := parsed.DiscographyGroup
+ if includeGroups == "" || includeGroups == "all" {
+ includeGroups = "album,single,compilation"
+ }
+
+ albumsURL := fmt.Sprintf("%s?include_groups=%s&limit=50", fmt.Sprintf(artistAlbumsBaseURL, parsed.ID), includeGroups)
+ var albums []albumSimplified
+ batchDelay := time.Duration(0)
+ if batch {
+ batchDelay = delay
+ }
+ batches, err := fetchPaging(ctx, c, albumsURL, token, batchDelay, &albums)
+ if err != nil {
+ return nil, err
+ }
+
+ return &discographyRaw{
+ Artist: artistData,
+ Albums: albums,
+ Token: token,
+ Discography: parsed.DiscographyGroup,
+ BatchEnabled: batch,
+ BatchCount: batches,
+ }, nil
+}
+
+func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token string) (*artistResponse, error) {
+ var artistData artistResponse
+ if err := c.getJSON(ctx, fmt.Sprintf(artistBaseURL, artistID), token, &artistData); err != nil {
+ return nil, err
+ }
+ return &artistData, nil
+}
+
+func (c *SpotifyMetadataClient) formatPlaylistData(raw *playlistRaw) PlaylistResponsePayload {
+ var info PlaylistInfoMetadata
+ info.Tracks.Total = raw.Data.Tracks.Total
+ info.Followers.Total = raw.Data.Followers.Total
+ info.Owner.DisplayName = raw.Data.Owner.DisplayName
+ info.Owner.Name = raw.Data.Name
+ info.Owner.Images = firstImageURL(raw.Data.Images)
+ if raw.BatchEnabled {
+ info.Batch = strconv.Itoa(maxInt(1, raw.BatchCount))
+ }
+
+ tracks := make([]AlbumTrackMetadata, 0, len(raw.Data.Tracks.Items))
+ for _, item := range raw.Data.Tracks.Items {
+ if item.Track == nil {
+ continue
+ }
+ tracks = append(tracks, AlbumTrackMetadata{
+ Artists: joinArtists(item.Track.Artists),
+ Name: item.Track.Name,
+ AlbumName: item.Track.Album.Name,
+ DurationMS: item.Track.DurationMS,
+ Images: firstNonEmpty(firstImageURL(item.Track.Album.Images), info.Owner.Images),
+ ReleaseDate: item.Track.Album.ReleaseDate,
+ TrackNumber: item.Track.TrackNumber,
+ ExternalURL: item.Track.ExternalURL.Spotify,
+ ISRC: item.Track.ExternalID.ISRC,
+ })
+ }
+
+ return PlaylistResponsePayload{
+ PlaylistInfo: info,
+ TrackList: tracks,
+ }
+}
+
+func (c *SpotifyMetadataClient) formatAlbumData(ctx context.Context, raw *albumRaw) (*AlbumResponsePayload, error) {
+ albumImage := firstImageURL(raw.Data.Images)
+ info := AlbumInfoMetadata{
+ TotalTracks: raw.Data.TotalTracks,
+ Name: raw.Data.Name,
+ ReleaseDate: raw.Data.ReleaseDate,
+ Artists: joinArtists(raw.Data.Artists),
+ Images: albumImage,
+ }
+ if raw.BatchEnabled {
+ info.Batch = strconv.Itoa(maxInt(1, raw.BatchCount))
+ }
+
+ tracks := make([]AlbumTrackMetadata, 0, len(raw.Data.Tracks.Items))
+ cache := make(map[string]string)
+ for _, item := range raw.Data.Tracks.Items {
+ isrc := c.fetchTrackISRC(ctx, item.ID, raw.Token, cache)
+ tracks = append(tracks, AlbumTrackMetadata{
+ Artists: joinArtists(item.Artists),
+ Name: item.Name,
+ AlbumName: raw.Data.Name,
+ DurationMS: item.DurationMS,
+ Images: albumImage,
+ ReleaseDate: raw.Data.ReleaseDate,
+ TrackNumber: item.TrackNumber,
+ ExternalURL: item.ExternalURL.Spotify,
+ ISRC: isrc,
+ })
+ }
+
+ return &AlbumResponsePayload{
+ AlbumInfo: info,
+ TrackList: tracks,
+ }, nil
+}
+
+func (c *SpotifyMetadataClient) formatArtistDiscographyData(ctx context.Context, raw *discographyRaw) (*ArtistDiscographyPayload, error) {
+ artistImage := firstImageURL(raw.Artist.Images)
+ discType := raw.Discography
+ if discType == "" {
+ discType = "all"
+ }
+
+ info := ArtistInfoMetadata{
+ Name: raw.Artist.Name,
+ Followers: raw.Artist.Followers.Total,
+ Genres: raw.Artist.Genres,
+ Images: artistImage,
+ ExternalURL: raw.Artist.ExternalURL.Spotify,
+ DiscographyType: discType,
+ TotalAlbums: len(raw.Albums),
+ }
+ if raw.BatchEnabled {
+ info.Batch = strconv.Itoa(maxInt(1, raw.BatchCount))
+ }
+
+ albumList := make([]DiscographyAlbumMetadata, 0, len(raw.Albums))
+ allTracks := make([]AlbumTrackMetadata, 0)
+ isrcCache := make(map[string]string)
+
+ for _, alb := range raw.Albums {
+ albumImage := firstImageURL(alb.Images)
+ albumList = append(albumList, DiscographyAlbumMetadata{
+ ID: alb.ID,
+ Name: alb.Name,
+ AlbumType: alb.AlbumType,
+ ReleaseDate: alb.ReleaseDate,
+ TotalTracks: alb.TotalTracks,
+ Artists: joinArtists(alb.Artists),
+ Images: albumImage,
+ ExternalURL: alb.ExternalURL.Spotify,
+ })
+
+ tracks, err := c.collectAlbumTracks(ctx, alb.ID, raw.Token)
+ if err != nil {
+ fmt.Printf("Error getting tracks for album %s: %v\n", alb.Name, err)
+ continue
+ }
+
+ for _, tr := range tracks {
+ isrc := c.fetchTrackISRC(ctx, tr.ID, raw.Token, isrcCache)
+ allTracks = append(allTracks, AlbumTrackMetadata{
+ Artists: joinArtists(tr.Artists),
+ Name: tr.Name,
+ AlbumName: alb.Name,
+ AlbumType: alb.AlbumType,
+ DurationMS: tr.DurationMS,
+ Images: albumImage,
+ ReleaseDate: alb.ReleaseDate,
+ TrackNumber: tr.TrackNumber,
+ ExternalURL: tr.ExternalURL.Spotify,
+ ISRC: isrc,
+ })
+ }
+ }
+
+ return &ArtistDiscographyPayload{
+ ArtistInfo: info,
+ AlbumList: albumList,
+ TrackList: allTracks,
+ }, nil
+}
+
+func formatArtistData(raw *artistResponse) ArtistResponsePayload {
+ if raw == nil {
+ return ArtistResponsePayload{}
+ }
+ payload := ArtistResponsePayload{}
+ payload.Artist.Name = raw.Name
+ payload.Artist.Followers = raw.Followers.Total
+ payload.Artist.Genres = raw.Genres
+ payload.Artist.Images = firstImageURL(raw.Images)
+ payload.Artist.ExternalURL = raw.ExternalURL.Spotify
+ payload.Artist.Popularity = raw.Popularity
+ return payload
+}
+
+func formatTrackData(raw *trackFull) TrackResponse {
+ if raw == nil {
+ return TrackResponse{}
+ }
+ return TrackResponse{
+ Track: TrackMetadata{
+ Artists: joinArtists(raw.Artists),
+ Name: raw.Name,
+ AlbumName: raw.Album.Name,
+ DurationMS: raw.DurationMS,
+ Images: firstImageURL(raw.Album.Images),
+ ReleaseDate: raw.Album.ReleaseDate,
+ TrackNumber: raw.TrackNumber,
+ ExternalURL: raw.ExternalURL.Spotify,
+ ISRC: raw.ExternalID.ISRC,
+ },
+ }
+}
+
+func (c *SpotifyMetadataClient) collectAlbumTracks(ctx context.Context, albumID, token string) ([]trackSimplified, error) {
+ url := fmt.Sprintf("%s/tracks?limit=50", fmt.Sprintf(albumBaseURL, albumID))
+ var tracks []trackSimplified
+ _, err := fetchPaging(ctx, c, url, token, 0, &tracks)
+ if err != nil {
+ return nil, err
+ }
+ return tracks, nil
+}
+
+func (c *SpotifyMetadataClient) fetchTrackISRC(ctx context.Context, trackID, token string, cache map[string]string) string {
+ if trackID == "" || token == "" {
+ return ""
+ }
+ if isrc, ok := cache[trackID]; ok {
+ return isrc
+ }
+
+ var data struct {
+ ExternalID externalID `json:"external_ids"`
+ }
+ if err := c.getJSON(ctx, fmt.Sprintf(trackBaseURL, trackID), token, &data); err != nil {
+ return ""
+ }
+ cache[trackID] = data.ExternalID.ISRC
+ return cache[trackID]
+}
+
+func fetchPaging[T any](ctx context.Context, client *SpotifyMetadataClient, nextURL, token string, delay time.Duration, dest *[]T) (int, error) {
+ batches := 0
+ for nextURL != "" {
+ select {
+ case <-ctx.Done():
+ return batches, ctx.Err()
+ default:
+ }
+
+ var page struct {
+ Items []T `json:"items"`
+ Next string `json:"next"`
+ }
+ if err := client.getJSON(ctx, nextURL, token, &page); err != nil {
+ return batches, err
+ }
+
+ *dest = append(*dest, page.Items...)
+ nextURL = stripLocaleParam(page.Next)
+ batches++
+
+ if nextURL != "" && delay > 0 {
+ if err := sleepWithContext(ctx, delay); err != nil {
+ return batches, err
+ }
+ }
+ }
+ return batches, nil
+}
+
+func (c *SpotifyMetadataClient) getJSON(ctx context.Context, endpoint, token string, dst interface{}) error {
+ for {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
+ if err != nil {
+ return err
+ }
+ headers := c.baseHeaders()
+ for key, values := range headers {
+ for _, v := range values {
+ req.Header.Add(key, v)
+ }
+ }
+ if token != "" {
+ req.Header.Set("Authorization", "Bearer "+token)
+ }
+
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return err
+ }
+ body, err := io.ReadAll(resp.Body)
+ resp.Body.Close()
+ if err != nil {
+ return err
+ }
+
+ if resp.StatusCode == http.StatusTooManyRequests {
+ if err := sleepWithContext(ctx, parseRetryAfter(resp.Header.Get("Retry-After"))); err != nil {
+ return err
+ }
+ continue
+ }
+
+ if resp.StatusCode != http.StatusOK {
+ return fmt.Errorf("spotify API returned status %d for %s", resp.StatusCode, endpoint)
+ }
+
+ return json.Unmarshal(body, dst)
+ }
+}
+
+func (c *SpotifyMetadataClient) baseHeaders() http.Header {
+ h := http.Header{}
+ h.Set("User-Agent", c.userAgent)
+ h.Set("Accept", "application/json")
+ h.Set("Accept-Language", "en-US,en;q=0.9")
+ h.Set("sec-ch-ua-platform", "\"Windows\"")
+ h.Set("sec-fetch-dest", "empty")
+ h.Set("sec-fetch-mode", "cors")
+ h.Set("sec-fetch-site", "same-origin")
+ h.Set("Referer", "https://open.spotify.com/")
+ h.Set("Origin", "https://open.spotify.com")
+ return h
+}
+
+func (c *SpotifyMetadataClient) randomUserAgent() string {
+ c.rngMu.Lock()
+ defer c.rngMu.Unlock()
+
+ macMajor := c.randRange(11, 15)
+ macMinor := c.randRange(4, 9)
+ webkitMajor := c.randRange(530, 537)
+ webkitMinor := c.randRange(30, 37)
+ chromeMajor := c.randRange(80, 105)
+ chromeBuild := c.randRange(3000, 4500)
+ chromePatch := c.randRange(60, 125)
+ safariMajor := c.randRange(530, 537)
+ safariMinor := c.randRange(30, 36)
+
+ return fmt.Sprintf(
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_%d_%d) AppleWebKit/%d.%d (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/%d.%d",
+ macMajor,
+ macMinor,
+ webkitMajor,
+ webkitMinor,
+ chromeMajor,
+ chromeBuild,
+ chromePatch,
+ safariMajor,
+ safariMinor,
+ )
+}
+
+func (c *SpotifyMetadataClient) randRange(min, max int) int {
+ if max <= min {
+ return min
+ }
+ return c.rng.Intn(max-min) + min
+}
+
+func (c *SpotifyMetadataClient) getAccessToken(ctx context.Context) (string, error) {
+ code, serverTime, version, err := c.generateTOTP(ctx)
+ if err != nil {
+ return "", err
+ }
+
+ timestampMS := time.Now().UnixMilli()
+ params := url.Values{}
+ params.Set("reason", "init")
+ params.Set("productType", "web-player")
+ params.Set("totp", code)
+ params.Set("totpServerTime", strconv.FormatInt(serverTime, 10))
+ params.Set("totpVer", strconv.Itoa(version))
+ params.Set("sTime", strconv.FormatInt(serverTime, 10))
+ params.Set("cTime", strconv.FormatInt(timestampMS, 10))
+ params.Set("buildVer", "web-player_2025-07-02_1720000000000_12345678")
+ params.Set("buildDate", "2025-07-02")
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, spotifyTokenURL, nil)
+ if err != nil {
+ return "", err
+ }
+ req.URL.RawQuery = params.Encode()
+ req.Header = c.baseHeaders()
+
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return "", err
+ }
+ body, err := io.ReadAll(resp.Body)
+ resp.Body.Close()
+ if err != nil {
+ return "", err
+ }
+
+ if resp.StatusCode != http.StatusOK {
+ return "", fmt.Errorf("failed to get access token. Status code: %d", resp.StatusCode)
+ }
+
+ var token accessTokenResponse
+ if err := json.Unmarshal(body, &token); err != nil {
+ return "", err
+ }
+ if token.AccessToken == "" {
+ return "", errors.New("failed to get access token: empty token received")
+ }
+ return token.AccessToken, nil
+}
+
+func (c *SpotifyMetadataClient) generateTOTP(ctx context.Context) (string, int64, int, error) {
+ secrets, _, err := c.fetchSecretBytes(ctx)
+ if err != nil {
+ return "", 0, 0, err
+ }
+ if len(secrets) == 0 {
+ return "", 0, 0, errors.New("no secrets available")
+ }
+
+ latest := secrets[0]
+ for _, entry := range secrets[1:] {
+ if entry.Version > latest.Version {
+ latest = entry
+ }
+ }
+
+ builder := strings.Builder{}
+ for idx, val := range latest.Secret {
+ processed := val ^ ((idx % 33) + 9)
+ builder.WriteString(strconv.Itoa(processed))
+ }
+
+ utfBytes := []byte(builder.String())
+ hexStr := hex.EncodeToString(utfBytes)
+ secretBytes, err := hex.DecodeString(hexStr)
+ if err != nil {
+ return "", 0, 0, err
+ }
+ b32Secret := base32.StdEncoding.EncodeToString(secretBytes)
+
+ serverTime, err := c.fetchServerTime(ctx)
+ if err != nil {
+ return "", 0, 0, err
+ }
+
+ code, err := computeTOTP(b32Secret, serverTime)
+ if err != nil {
+ return "", 0, 0, err
+ }
+
+ return code, serverTime, latest.Version, nil
+}
+
+func (c *SpotifyMetadataClient) fetchSecretBytes(ctx context.Context) ([]secretEntry, bool, error) {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, secretBytesRemotePath, nil)
+ if err == nil {
+ resp, err := c.httpClient.Do(req)
+ if err == nil {
+ body, readErr := io.ReadAll(resp.Body)
+ resp.Body.Close()
+ if readErr == nil && resp.StatusCode == http.StatusOK {
+ var secrets []secretEntry
+ if jsonErr := json.Unmarshal(body, &secrets); jsonErr == nil {
+ return secrets, false, nil
+ }
+ }
+ }
+ }
+
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return nil, false, fmt.Errorf("GitHub fetch failed and could not resolve home directory: %w", err)
+ }
+ localPath := filepath.Join(home, ".spotify-secret", "secretBytes.json")
+ data, err := os.ReadFile(localPath)
+ if err != nil {
+ return nil, false, fmt.Errorf("failed to fetch secrets from both GitHub and local: %w", err)
+ }
+
+ var secrets []secretEntry
+ if err := json.Unmarshal(data, &secrets); err != nil {
+ return nil, false, fmt.Errorf("failed to process local secrets: %w", err)
+ }
+ return secrets, true, nil
+}
+
+func (c *SpotifyMetadataClient) fetchServerTime(ctx context.Context) (int64, error) {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://open.spotify.com/api/server-time", nil)
+ if err != nil {
+ return 0, err
+ }
+ req.Header = c.serverTimeHeaders()
+
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return 0, err
+ }
+ body, err := io.ReadAll(resp.Body)
+ resp.Body.Close()
+ if err != nil {
+ return 0, err
+ }
+
+ if resp.StatusCode != http.StatusOK {
+ return 0, fmt.Errorf("failed to get server time. Status code: %d", resp.StatusCode)
+ }
+
+ var payload serverTimeResponse
+ if err := json.Unmarshal(body, &payload); err != nil {
+ return 0, err
+ }
+ if payload.ServerTime == 0 {
+ return 0, errors.New("failed to fetch server time from Spotify")
+ }
+ return payload.ServerTime, nil
+}
+
+func (c *SpotifyMetadataClient) serverTimeHeaders() http.Header {
+ h := http.Header{}
+ h.Set("Host", "open.spotify.com")
+ h.Set("User-Agent", c.randomUserAgent())
+ h.Set("Accept", "*/*")
+ return h
+}
+
+func computeTOTP(b32Secret string, timestamp int64) (string, error) {
+ normalized := strings.ToUpper(strings.ReplaceAll(b32Secret, " ", ""))
+ key, err := base32.StdEncoding.DecodeString(normalized)
+ if err != nil {
+ return "", err
+ }
+
+ // Normalise milliseconds if necessary.
+ if timestamp > 1_000_000_000_000 {
+ timestamp /= 1000
+ }
+
+ counter := uint64(timestamp / 30)
+ var buf [8]byte
+ binary.BigEndian.PutUint64(buf[:], counter)
+
+ mac := hmac.New(sha1.New, key)
+ if _, err := mac.Write(buf[:]); err != nil {
+ return "", err
+ }
+ sum := mac.Sum(nil)
+ if len(sum) < 20 {
+ return "", errors.New("unexpected hmac length for TOTP")
+ }
+
+ offset := sum[len(sum)-1] & 0x0f
+ binaryCode := (int(sum[offset])&0x7f)<<24 |
+ (int(sum[offset+1])&0xff)<<16 |
+ (int(sum[offset+2])&0xff)<<8 |
+ (int(sum[offset+3]) & 0xff)
+ otp := binaryCode % 1_000_000
+ return fmt.Sprintf("%06d", otp), nil
+}
+
+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 == "embed.spotify.com" {
+ if parsed.RawQuery == "" {
+ return spotifyURI{}, errInvalidSpotifyURL
+ }
+ qs, _ := url.ParseQuery(parsed.RawQuery)
+ embedded := qs.Get("uri")
+ if embedded == "" {
+ return spotifyURI{}, errInvalidSpotifyURL
+ }
+ return parseSpotifyURI(embedded)
+ }
+
+ if parsed.Scheme == "" && parsed.Host == "" {
+ id := strings.Trim(strings.TrimSpace(parsed.Path), "/")
+ if id == "" {
+ return spotifyURI{}, errInvalidSpotifyURL
+ }
+ return spotifyURI{Type: "playlist", ID: id}, nil
+ }
+
+ 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) == 4 && parts[2] == "playlist" {
+ return spotifyURI{Type: "playlist", ID: parts[3]}, 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 stripLocaleParam(raw string) string {
+ if raw == "" {
+ return ""
+ }
+ if idx := strings.Index(raw, "&locale="); idx != -1 {
+ return raw[:idx]
+ }
+ if idx := strings.Index(raw, "?locale="); idx != -1 {
+ return raw[:idx]
+ }
+ return raw
+}
+
+func firstImageURL(images []image) string {
+ if len(images) == 0 {
+ return ""
+ }
+ return images[0].URL
+}
+
+func joinArtists(artists []artist) string {
+ if len(artists) == 0 {
+ return ""
+ }
+ names := make([]string, 0, len(artists))
+ for _, a := range artists {
+ if a.Name != "" {
+ names = append(names, a.Name)
+ }
+ }
+ return strings.Join(names, ", ")
+}
+
+func firstNonEmpty(values ...string) string {
+ for _, v := range values {
+ if strings.TrimSpace(v) != "" {
+ return v
+ }
+ }
+ return ""
+}
+
+func parseRetryAfter(value string) time.Duration {
+ if value == "" {
+ return 5 * time.Second
+ }
+ secs, err := strconv.Atoi(strings.TrimSpace(value))
+ if err != nil {
+ return 5 * time.Second
+ }
+ return time.Duration(secs+1) * time.Second
+}
+
+func sleepWithContext(ctx context.Context, d time.Duration) error {
+ if d <= 0 {
+ return nil
+ }
+ timer := time.NewTimer(d)
+ defer timer.Stop()
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-timer.C:
+ return nil
+ }
+}
+
+func maxInt(a, b int) int {
+ if a > b {
+ return a
+ }
+ return b
+}
diff --git a/backend/tidal.go b/backend/tidal.go
new file mode 100644
index 0000000..2e6c7cc
--- /dev/null
+++ b/backend/tidal.go
@@ -0,0 +1,434 @@
+package backend
+
+import (
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+)
+
+type TidalDownloader struct {
+ client *http.Client
+ timeout time.Duration
+ maxRetries int
+ clientID string
+ clientSecret string
+ apiURL string
+}
+
+type TidalSearchResponse struct {
+ Limit int `json:"limit"`
+ Offset int `json:"offset"`
+ TotalNumberOfItems int `json:"totalNumberOfItems"`
+ Items []TidalTrack `json:"items"`
+}
+
+type TidalTrack struct {
+ ID int64 `json:"id"`
+ Title string `json:"title"`
+ ISRC string `json:"isrc"`
+ AudioQuality string `json:"audioQuality"`
+ TrackNumber int `json:"trackNumber"`
+ VolumeNumber int `json:"volumeNumber"`
+ Duration int `json:"duration"`
+ Copyright string `json:"copyright"`
+ Explicit bool `json:"explicit"`
+ Album struct {
+ Title string `json:"title"`
+ Cover string `json:"cover"`
+ ReleaseDate string `json:"releaseDate"`
+ } `json:"album"`
+ Artists []struct {
+ Name string `json:"name"`
+ } `json:"artists"`
+ Artist struct {
+ Name string `json:"name"`
+ } `json:"artist"`
+ MediaMetadata struct {
+ Tags []string `json:"tags"`
+ } `json:"mediaMetadata"`
+}
+
+type TidalAPIResponse struct {
+ OriginalTrackURL string `json:"OriginalTrackUrl"`
+}
+
+type TidalAPIInfo struct {
+ URL string `json:"url"`
+ Status string `json:"status"`
+}
+
+func NewTidalDownloader(apiURL string) *TidalDownloader {
+ clientID, _ := base64.StdEncoding.DecodeString("NkJEU1JkcEs5aHFFQlRnVQ==")
+ clientSecret, _ := base64.StdEncoding.DecodeString("eGV1UG1ZN25icFo5SUliTEFjUTkzc2hrYTFWTmhlVUFxTjZJY3N6alRHOD0=")
+
+ return &TidalDownloader{
+ client: &http.Client{
+ Timeout: 60 * time.Second,
+ },
+ timeout: 30 * time.Second,
+ maxRetries: 3,
+ clientID: string(clientID),
+ clientSecret: string(clientSecret),
+ apiURL: apiURL,
+ }
+}
+
+func (t *TidalDownloader) GetAvailableAPIs() ([]string, error) {
+ resp, err := http.Get("https://raw.githubusercontent.com/afkarxyz/SpotiFLAC/refs/heads/main/tidal.json")
+ if err != nil {
+ return nil, fmt.Errorf("failed to fetch API list: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != 200 {
+ return nil, fmt.Errorf("failed to fetch API list: HTTP %d", resp.StatusCode)
+ }
+
+ var apiList []string
+ if err := json.NewDecoder(resp.Body).Decode(&apiList); err != nil {
+ return nil, fmt.Errorf("failed to decode API list: %w", err)
+ }
+
+ var apis []string
+ for _, api := range apiList {
+ apis = append(apis, "https://"+api)
+ }
+
+ return apis, nil
+}
+
+func (t *TidalDownloader) GetAccessToken() (string, error) {
+ data := fmt.Sprintf("client_id=%s&grant_type=client_credentials", t.clientID)
+
+ req, err := http.NewRequest("POST", "https://auth.tidal.com/v1/oauth2/token", strings.NewReader(data))
+ if err != nil {
+ return "", err
+ }
+
+ req.SetBasicAuth(t.clientID, t.clientSecret)
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+
+ resp, err := t.client.Do(req)
+ if err != nil {
+ return "", err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != 200 {
+ return "", fmt.Errorf("failed to get access token: HTTP %d", resp.StatusCode)
+ }
+
+ var result struct {
+ AccessToken string `json:"access_token"`
+ }
+
+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
+ return "", err
+ }
+
+ return result.AccessToken, nil
+}
+
+func (t *TidalDownloader) SearchTracks(query string) (*TidalSearchResponse, error) {
+ token, err := t.GetAccessToken()
+ if err != nil {
+ return nil, fmt.Errorf("failed to get access token: %w", err)
+ }
+
+ // URL encode the query parameter
+ searchURL := fmt.Sprintf("https://api.tidal.com/v1/search/tracks?query=%s&limit=25&offset=0&countryCode=US", url.QueryEscape(query))
+
+ req, err := http.NewRequest("GET", searchURL, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ req.Header.Set("Authorization", "Bearer "+token)
+
+ resp, err := t.client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != 200 {
+ body, _ := io.ReadAll(resp.Body)
+ return nil, fmt.Errorf("search failed: HTTP %d - %s", resp.StatusCode, string(body))
+ }
+
+ var result TidalSearchResponse
+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
+ return nil, err
+ }
+
+ return &result, nil
+}
+
+func (t *TidalDownloader) GetTrackInfo(query, isrc string) (*TidalTrack, error) {
+ fmt.Printf("Fetching: %s", query)
+ if isrc != "" {
+ fmt.Printf(" (ISRC: %s)", isrc)
+ }
+ fmt.Println()
+
+ result, err := t.SearchTracks(query)
+ if err != nil {
+ return nil, err
+ }
+
+ if len(result.Items) == 0 {
+ return nil, fmt.Errorf("no tracks found for query: %s", query)
+ }
+
+ var selectedTrack *TidalTrack
+
+ if isrc != "" {
+ var isrcMatches []TidalTrack
+ for _, item := range result.Items {
+ if item.ISRC == isrc {
+ isrcMatches = append(isrcMatches, item)
+ }
+ }
+
+ if len(isrcMatches) > 1 {
+ for _, item := range isrcMatches {
+ for _, tag := range item.MediaMetadata.Tags {
+ if tag == "HIRES_LOSSLESS" {
+ selectedTrack = &item
+ break
+ }
+ }
+ if selectedTrack != nil {
+ break
+ }
+ }
+ if selectedTrack == nil {
+ selectedTrack = &isrcMatches[0]
+ }
+ } else if len(isrcMatches) == 1 {
+ selectedTrack = &isrcMatches[0]
+ } else {
+ selectedTrack = &result.Items[0]
+ }
+ } else {
+ selectedTrack = &result.Items[0]
+ }
+
+ if selectedTrack == nil {
+ return nil, fmt.Errorf("track not found")
+ }
+
+ fmt.Printf("Found: %s (%s)\n", selectedTrack.Title, selectedTrack.AudioQuality)
+ return selectedTrack, nil
+}
+
+func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (string, error) {
+ fmt.Println("Fetching URL...")
+
+ url := fmt.Sprintf("%s/track/?id=%d&quality=%s", t.apiURL, trackID, quality)
+
+ resp, err := t.client.Get(url)
+ if err != nil {
+ return "", fmt.Errorf("failed to get download URL: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != 200 {
+ return "", fmt.Errorf("API returned status code: %d", resp.StatusCode)
+ }
+
+ var apiResponses []TidalAPIResponse
+ if err := json.NewDecoder(resp.Body).Decode(&apiResponses); err != nil {
+ return "", fmt.Errorf("failed to decode response: %w", err)
+ }
+
+ if len(apiResponses) == 0 {
+ return "", fmt.Errorf("no download URL in response")
+ }
+
+ for _, item := range apiResponses {
+ if item.OriginalTrackURL != "" {
+ fmt.Println("URL found")
+ return item.OriginalTrackURL, nil
+ }
+ }
+
+ return "", fmt.Errorf("download URL not found in response")
+}
+
+func (t *TidalDownloader) DownloadAlbumArt(albumID string) ([]byte, error) {
+ albumID = strings.ReplaceAll(albumID, "-", "/")
+ artURL := fmt.Sprintf("https://resources.tidal.com/images/%s/1280x1280.jpg", albumID)
+
+ resp, err := t.client.Get(artURL)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != 200 {
+ return nil, fmt.Errorf("failed to download album art: HTTP %d", resp.StatusCode)
+ }
+
+ return io.ReadAll(resp.Body)
+}
+
+func (t *TidalDownloader) DownloadFile(url, filepath string) error {
+ resp, err := t.client.Get(url)
+ 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()
+
+ _, err = io.Copy(out, resp.Body)
+ if err != nil {
+ return fmt.Errorf("failed to write file: %w", err)
+ }
+
+ fmt.Println("Download complete")
+ return nil
+}
+
+func (t *TidalDownloader) Download(query, isrc, outputDir, quality string) (string, error) {
+ if outputDir != "." {
+ if err := os.MkdirAll(outputDir, 0755); err != nil {
+ return "", fmt.Errorf("directory error: %w", err)
+ }
+ }
+
+ trackInfo, err := t.GetTrackInfo(query, isrc)
+ if err != nil {
+ return "", err
+ }
+
+ if trackInfo.ID == 0 {
+ return "", fmt.Errorf("no track ID found")
+ }
+
+ var artists []string
+ if len(trackInfo.Artists) > 0 {
+ for _, artist := range trackInfo.Artists {
+ if artist.Name != "" {
+ artists = append(artists, artist.Name)
+ }
+ }
+ } else if trackInfo.Artist.Name != "" {
+ artists = append(artists, trackInfo.Artist.Name)
+ }
+
+ artistName := "Unknown Artist"
+ if len(artists) > 0 {
+ artistName = strings.Join(artists, ", ")
+ }
+ artistName = sanitizeFilename(artistName)
+
+ trackTitle := sanitizeFilename(trackInfo.Title)
+ if trackTitle == "" {
+ trackTitle = fmt.Sprintf("track_%d", trackInfo.ID)
+ }
+
+ outputFilename := filepath.Join(outputDir, fmt.Sprintf("%s - %s.flac", artistName, trackTitle))
+
+ if fileInfo, err := os.Stat(outputFilename); err == nil && fileInfo.Size() > 0 {
+ fmt.Printf("File already exists: %s (%.2f MB)\n", outputFilename, float64(fileInfo.Size())/(1024*1024))
+ return outputFilename, nil
+ }
+
+ downloadURL, err := t.GetDownloadURL(trackInfo.ID, quality)
+ if err != nil {
+ return "", err
+ }
+
+ fmt.Printf("Downloading to: %s\n", outputFilename)
+ if err := t.DownloadFile(downloadURL, outputFilename); err != nil {
+ return "", err
+ }
+
+ fmt.Println("Adding metadata...")
+
+ coverPath := ""
+ if trackInfo.Album.Cover != "" {
+ coverPath = outputFilename + ".cover.jpg"
+ albumArt, err := t.DownloadAlbumArt(trackInfo.Album.Cover)
+ if err != nil {
+ fmt.Printf("Warning: Failed to download album art: %v\n", err)
+ } else {
+ if err := os.WriteFile(coverPath, albumArt, 0644); err != nil {
+ fmt.Printf("Warning: Failed to save album art: %v\n", err)
+ } else {
+ defer os.Remove(coverPath)
+ fmt.Println("Album art downloaded")
+ }
+ }
+ }
+
+ releaseYear := ""
+ if len(trackInfo.Album.ReleaseDate) >= 4 {
+ releaseYear = trackInfo.Album.ReleaseDate[:4]
+ }
+
+ metadata := Metadata{
+ Title: trackInfo.Title,
+ Artist: artistName,
+ Album: trackInfo.Album.Title,
+ Date: releaseYear,
+ TrackNumber: trackInfo.TrackNumber,
+ DiscNumber: trackInfo.VolumeNumber,
+ ISRC: trackInfo.ISRC,
+ }
+
+ if err := EmbedMetadata(outputFilename, metadata, coverPath); err != nil {
+ fmt.Printf("Tagging failed: %v\n", err)
+ } else {
+ fmt.Println("Metadata saved")
+ }
+
+ fmt.Println("Done")
+ return outputFilename, nil
+}
+
+func (t *TidalDownloader) DownloadWithFallback(query, isrc, outputDir, quality string) (string, error) {
+ apis, err := t.GetAvailableAPIs()
+ if err != nil {
+ return "", fmt.Errorf("no APIs available for fallback: %w", err)
+ }
+
+ var lastError error
+ for i, apiURL := range apis {
+ fmt.Printf("[Auto Fallback %d/%d] Trying: %s\n", i+1, len(apis), apiURL)
+
+ fallbackDownloader := NewTidalDownloader(apiURL)
+
+ result, err := fallbackDownloader.Download(query, isrc, outputDir, quality)
+ if err == nil {
+ fmt.Printf("✓ Success with: %s\n", apiURL)
+ return result, nil
+ }
+
+ lastError = err
+ errMsg := err.Error()
+ if len(errMsg) > 80 {
+ errMsg = errMsg[:80]
+ }
+ fmt.Printf("✗ Failed with %s: %s\n", apiURL, errMsg)
+ }
+
+ return "", fmt.Errorf("all %d APIs failed. Last error: %v", len(apis), lastError)
+}
diff --git a/deezerDL.py b/deezerDL.py
deleted file mode 100644
index 6c4e422..0000000
--- a/deezerDL.py
+++ /dev/null
@@ -1,241 +0,0 @@
-import requests
-import asyncio
-import os
-import sys
-from mutagen.flac import FLAC
-from random import randrange
-
-def get_random_user_agent():
- return f"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_{randrange(11, 15)}_{randrange(4, 9)}) AppleWebKit/{randrange(530, 537)}.{randrange(30, 37)} (KHTML, like Gecko) Chrome/{randrange(80, 105)}.0.{randrange(3000, 4500)}.{randrange(60, 125)} Safari/{randrange(530, 537)}.{randrange(30, 36)}"
-
-class DeezerDownloader:
- def __init__(self):
- self.session = requests.Session()
- self.session.headers.update({
- 'User-Agent': get_random_user_agent()
- })
- self.progress_callback = None
-
- def set_progress_callback(self, callback):
- self.progress_callback = callback
-
- def get_track_by_isrc(self, isrc):
- try:
- url = f"https://api.deezer.com/2.0/track/isrc:{isrc}"
- response = self.session.get(url)
- response.raise_for_status()
-
- data = response.json()
-
- if 'error' in data:
- print(f"Error from Deezer API: {data['error']['message']}")
- return None
-
- return data
- except requests.exceptions.RequestException as e:
- print(f"Error fetching track data: {e}")
- return None
-
- def extract_metadata(self, track_data):
- metadata = {}
-
- metadata['title'] = track_data.get('title', '')
- metadata['title_short'] = track_data.get('title_short', '')
- metadata['duration'] = track_data.get('duration', 0)
- metadata['track_position'] = track_data.get('track_position', 1)
- metadata['disk_number'] = track_data.get('disk_number', 1)
- metadata['isrc'] = track_data.get('isrc', '')
- metadata['release_date'] = track_data.get('release_date', '')
- metadata['explicit_lyrics'] = track_data.get('explicit_lyrics', False)
-
- if 'artist' in track_data:
- metadata['artist'] = track_data['artist'].get('name', '')
- metadata['artist_id'] = track_data['artist'].get('id', '')
-
- if 'contributors' in track_data:
- artists = []
- for contributor in track_data['contributors']:
- if contributor.get('role') == 'Main':
- artists.append(contributor.get('name', ''))
- metadata['artists'] = ', '.join(artists) if artists else metadata.get('artist', '')
-
- if 'album' in track_data:
- album = track_data['album']
- metadata['album'] = album.get('title', '')
- metadata['album_id'] = album.get('id', '')
- metadata['cover_url'] = album.get('cover_xl', album.get('cover_big', ''))
- metadata['cover_md5'] = album.get('md5_image', '')
-
- metadata['deezer_link'] = track_data.get('link', '')
- metadata['preview_url'] = track_data.get('preview', '')
-
- return metadata
-
- def download_cover_art(self, cover_url, filename):
- if not cover_url:
- return None
-
- try:
- response = self.session.get(cover_url)
- response.raise_for_status()
-
- cover_path = f"{filename}_cover.jpg"
- with open(cover_path, 'wb') as f:
- f.write(response.content)
-
- return cover_path
- except Exception as e:
- print(f"Error downloading cover art: {e}")
- return None
-
- def embed_metadata(self, file_path, metadata, cover_path=None):
- try:
- audio = FLAC(file_path)
-
- audio.clear()
-
- if metadata.get('title'):
- audio['TITLE'] = metadata['title']
- if metadata.get('artists'):
- audio['ARTIST'] = metadata['artists']
- elif metadata.get('artist'):
- audio['ARTIST'] = metadata['artist']
- if metadata.get('album'):
- audio['ALBUM'] = metadata['album']
- if metadata.get('release_date'):
- audio['DATE'] = metadata['release_date']
- if metadata.get('track_position'):
- audio['TRACKNUMBER'] = str(metadata['track_position'])
- if metadata.get('disk_number'):
- audio['DISCNUMBER'] = str(metadata['disk_number'])
- if metadata.get('isrc'):
- audio['ISRC'] = metadata['isrc']
-
- if cover_path and os.path.exists(cover_path):
- with open(cover_path, 'rb') as f:
- cover_data = f.read()
-
- from mutagen.flac import Picture
- picture = Picture()
- picture.type = 3
- picture.mime = 'image/jpeg'
- picture.desc = 'Cover'
- picture.data = cover_data
- audio.add_picture(picture)
-
- audio.save()
- print(f"Metadata embedded successfully in {file_path}")
-
- except Exception as e:
- print(f"Error embedding metadata: {e}")
-
- async def download_by_isrc(self, isrc, output_dir="."):
- print(f"Fetching track info for ISRC: {isrc}")
-
- track_data = self.get_track_by_isrc(isrc)
- if not track_data:
- print("Failed to get track data from Deezer API")
- return False
-
- metadata = self.extract_metadata(track_data)
- print(f"Found track: {metadata.get('artists', 'Unknown')} - {metadata.get('title', 'Unknown')}")
-
- track_id = track_data.get('id')
- if not track_id:
- print("No track ID found in Deezer API response")
- return False
-
- print(f"Using track ID: {track_id}")
-
- api_url = f"https://api.deezmate.com/dl/{track_id}"
- print(f"Requesting download links from: {api_url}")
-
- try:
- response = self.session.get(api_url)
- response.raise_for_status()
- api_data = response.json()
-
- if not api_data.get('success'):
- print("API request failed")
- return False
-
- links = api_data.get('links', {})
- flac_url = links.get('flac')
-
- if not flac_url:
- print("No FLAC download link found in API response")
- return False
-
- print(f"Successfully obtained FLAC download URL")
-
- except Exception as e:
- print(f"Error getting download URL from API: {e}")
- return False
-
- print("Downloading FLAC file...")
- try:
- response = self.session.get(flac_url)
- response.raise_for_status()
-
- safe_title = "".join(c for c in metadata.get('title', 'Unknown') if c.isalnum() or c in (' ', '-', '_')).rstrip()
- safe_artist = "".join(c for c in metadata.get('artists', 'Unknown') if c.isalnum() or c in (' ', '-', '_')).rstrip()
- filename = f"{safe_artist} - {safe_title}.flac"
- file_path = os.path.join(output_dir, filename)
-
- with open(file_path, 'wb') as f:
- f.write(response.content)
-
- downloaded = len(response.content)
- print(f"File size: {downloaded} bytes ({downloaded / (1024*1024):.2f} MB)")
-
- if self.progress_callback:
- self.progress_callback(downloaded, downloaded)
-
- print(f"Downloaded: {file_path}")
-
- cover_path = None
- if metadata.get('cover_url'):
- print("Downloading cover art...")
- cover_path = self.download_cover_art(metadata['cover_url'],
- os.path.join(output_dir, f"{safe_artist} - {safe_title}"))
-
- print("Embedding metadata...")
- self.embed_metadata(file_path, metadata, cover_path)
-
- if cover_path and os.path.exists(cover_path):
- os.remove(cover_path)
-
- print(f"Successfully downloaded and tagged: {filename}")
- return True
-
- except Exception as e:
- print(f"Error downloading file: {e}")
- return False
-
-async def main():
- print("=== DeezerDL - Deezer Downloader ===")
- downloader = DeezerDownloader()
-
- isrc = "USAT22409172"
- output_dir = "."
-
- success = await downloader.download_by_isrc(isrc, output_dir)
- if success:
- print("Download completed successfully!")
- else:
- print("Download failed!")
-
-if __name__ == "__main__":
- try:
- import sys
- if sys.platform == "win32":
- import os
- os.system("chcp 65001 > nul")
- try:
- sys.stdout.reconfigure(encoding='utf-8')
- except:
- pass
- except:
- pass
-
- asyncio.run(main())
\ No newline at end of file
diff --git a/frontend/.gitignore b/frontend/.gitignore
new file mode 100644
index 0000000..a547bf3
--- /dev/null
+++ b/frontend/.gitignore
@@ -0,0 +1,24 @@
+# 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
new file mode 100644
index 0000000..2b0833f
--- /dev/null
+++ b/frontend/components.json
@@ -0,0 +1,22 @@
+{
+ "$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": {}
+}
diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js
new file mode 100644
index 0000000..5e6b472
--- /dev/null
+++ b/frontend/eslint.config.js
@@ -0,0 +1,23 @@
+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
new file mode 100644
index 0000000..fc4549a
--- /dev/null
+++ b/frontend/index.html
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+ SpotiFLAC
+
+
+
+
+
+
diff --git a/frontend/package.json b/frontend/package.json
new file mode 100644
index 0000000..1aed2b3
--- /dev/null
+++ b/frontend/package.json
@@ -0,0 +1,50 @@
+{
+ "name": "frontend",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc -b && vite build",
+ "lint": "eslint .",
+ "preview": "vite preview",
+ "generate-icon": "node scripts/generate-icon.js"
+ },
+ "dependencies": {
+ "@radix-ui/react-checkbox": "^1.3.3",
+ "@radix-ui/react-dialog": "^1.1.15",
+ "@radix-ui/react-label": "^2.1.8",
+ "@radix-ui/react-progress": "^1.1.8",
+ "@radix-ui/react-radio-group": "^1.3.8",
+ "@radix-ui/react-select": "^2.2.6",
+ "@radix-ui/react-slot": "^1.2.4",
+ "@radix-ui/react-switch": "^1.2.6",
+ "@radix-ui/react-tabs": "^1.1.13",
+ "@tailwindcss/vite": "^4.1.17",
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
+ "lucide-react": "^0.554.0",
+ "next-themes": "^0.4.6",
+ "react": "^19.2.0",
+ "react-dom": "^19.2.0",
+ "sonner": "^2.0.7",
+ "tailwind-merge": "^3.4.0",
+ "tailwindcss": "^4.1.17"
+ },
+ "devDependencies": {
+ "@eslint/js": "^9.39.1",
+ "@types/node": "^24.10.1",
+ "@types/react": "^19.2.6",
+ "@types/react-dom": "^19.2.3",
+ "@vitejs/plugin-react": "^5.1.1",
+ "eslint": "^9.39.1",
+ "eslint-plugin-react-hooks": "^7.0.1",
+ "eslint-plugin-react-refresh": "^0.4.24",
+ "globals": "^16.5.0",
+ "sharp": "^0.34.5",
+ "tw-animate-css": "^1.4.0",
+ "typescript": "~5.9.3",
+ "typescript-eslint": "^8.47.0",
+ "vite": "^7.2.4"
+ }
+}
diff --git a/frontend/package.json.md5 b/frontend/package.json.md5
new file mode 100644
index 0000000..4fcb36d
--- /dev/null
+++ b/frontend/package.json.md5
@@ -0,0 +1 @@
+1c863b339b3c07aabe6b968fcd4e46ab
\ No newline at end of file
diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml
new file mode 100644
index 0000000..38ab8c9
--- /dev/null
+++ b/frontend/pnpm-lock.yaml
@@ -0,0 +1,3782 @@
+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.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@radix-ui/react-dialog':
+ specifier: ^1.1.15
+ version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@radix-ui/react-label':
+ specifier: ^2.1.8
+ version: 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@radix-ui/react-progress':
+ specifier: ^1.1.8
+ version: 1.1.8(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@radix-ui/react-radio-group':
+ specifier: ^1.3.8
+ version: 1.3.8(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@radix-ui/react-select':
+ specifier: ^2.2.6
+ version: 2.2.6(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@radix-ui/react-slot':
+ specifier: ^1.2.4
+ version: 1.2.4(@types/react@19.2.6)(react@19.2.0)
+ '@radix-ui/react-switch':
+ specifier: ^1.2.6
+ version: 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@radix-ui/react-tabs':
+ specifier: ^1.1.13
+ version: 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@tailwindcss/vite':
+ specifier: ^4.1.17
+ version: 4.1.17(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2))
+ class-variance-authority:
+ specifier: ^0.7.1
+ version: 0.7.1
+ clsx:
+ specifier: ^2.1.1
+ version: 2.1.1
+ lucide-react:
+ specifier: ^0.554.0
+ version: 0.554.0(react@19.2.0)
+ next-themes:
+ specifier: ^0.4.6
+ version: 0.4.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ react:
+ specifier: ^19.2.0
+ version: 19.2.0
+ react-dom:
+ specifier: ^19.2.0
+ version: 19.2.0(react@19.2.0)
+ sonner:
+ specifier: ^2.0.7
+ version: 2.0.7(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ tailwind-merge:
+ specifier: ^3.4.0
+ version: 3.4.0
+ tailwindcss:
+ specifier: ^4.1.17
+ version: 4.1.17
+ devDependencies:
+ '@eslint/js':
+ specifier: ^9.39.1
+ version: 9.39.1
+ '@types/node':
+ specifier: ^24.10.1
+ version: 24.10.1
+ '@types/react':
+ specifier: ^19.2.6
+ version: 19.2.6
+ '@types/react-dom':
+ specifier: ^19.2.3
+ version: 19.2.3(@types/react@19.2.6)
+ '@vitejs/plugin-react':
+ specifier: ^5.1.1
+ version: 5.1.1(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2))
+ eslint:
+ specifier: ^9.39.1
+ version: 9.39.1(jiti@2.6.1)
+ eslint-plugin-react-hooks:
+ specifier: ^7.0.1
+ version: 7.0.1(eslint@9.39.1(jiti@2.6.1))
+ eslint-plugin-react-refresh:
+ specifier: ^0.4.24
+ version: 0.4.24(eslint@9.39.1(jiti@2.6.1))
+ globals:
+ specifier: ^16.5.0
+ version: 16.5.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.47.0
+ version: 8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)
+ vite:
+ specifier: ^7.2.4
+ version: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)
+
+packages:
+
+ '@babel/code-frame@7.27.1':
+ resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/compat-data@7.28.5':
+ resolution: {integrity: sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/core@7.28.5':
+ resolution: {integrity: sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/generator@7.28.5':
+ resolution: {integrity: sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-compilation-targets@7.27.2':
+ resolution: {integrity: sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==}
+ 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.27.1':
+ resolution: {integrity: sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-module-transforms@7.28.3':
+ resolution: {integrity: sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0
+
+ '@babel/helper-plugin-utils@7.27.1':
+ resolution: {integrity: sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==}
+ 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.4':
+ resolution: {integrity: sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/parser@7.28.5':
+ resolution: {integrity: sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==}
+ 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.27.2':
+ resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/traverse@7.28.5':
+ resolution: {integrity: sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/types@7.28.5':
+ resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==}
+ engines: {node: '>=6.9.0'}
+
+ '@emnapi/runtime@1.7.1':
+ resolution: {integrity: sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==}
+
+ '@esbuild/aix-ppc64@0.25.12':
+ resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==}
+ engines: {node: '>=18'}
+ cpu: [ppc64]
+ os: [aix]
+
+ '@esbuild/android-arm64@0.25.12':
+ resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [android]
+
+ '@esbuild/android-arm@0.25.12':
+ resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==}
+ engines: {node: '>=18'}
+ cpu: [arm]
+ os: [android]
+
+ '@esbuild/android-x64@0.25.12':
+ resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [android]
+
+ '@esbuild/darwin-arm64@0.25.12':
+ resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@esbuild/darwin-x64@0.25.12':
+ resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@esbuild/freebsd-arm64@0.25.12':
+ resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [freebsd]
+
+ '@esbuild/freebsd-x64@0.25.12':
+ resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@esbuild/linux-arm64@0.25.12':
+ resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@esbuild/linux-arm@0.25.12':
+ resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==}
+ engines: {node: '>=18'}
+ cpu: [arm]
+ os: [linux]
+
+ '@esbuild/linux-ia32@0.25.12':
+ resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==}
+ engines: {node: '>=18'}
+ cpu: [ia32]
+ os: [linux]
+
+ '@esbuild/linux-loong64@0.25.12':
+ resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==}
+ engines: {node: '>=18'}
+ cpu: [loong64]
+ os: [linux]
+
+ '@esbuild/linux-mips64el@0.25.12':
+ resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==}
+ engines: {node: '>=18'}
+ cpu: [mips64el]
+ os: [linux]
+
+ '@esbuild/linux-ppc64@0.25.12':
+ resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==}
+ engines: {node: '>=18'}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@esbuild/linux-riscv64@0.25.12':
+ resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==}
+ engines: {node: '>=18'}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@esbuild/linux-s390x@0.25.12':
+ resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==}
+ engines: {node: '>=18'}
+ cpu: [s390x]
+ os: [linux]
+
+ '@esbuild/linux-x64@0.25.12':
+ resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [linux]
+
+ '@esbuild/netbsd-arm64@0.25.12':
+ resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [netbsd]
+
+ '@esbuild/netbsd-x64@0.25.12':
+ resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [netbsd]
+
+ '@esbuild/openbsd-arm64@0.25.12':
+ resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [openbsd]
+
+ '@esbuild/openbsd-x64@0.25.12':
+ resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [openbsd]
+
+ '@esbuild/openharmony-arm64@0.25.12':
+ resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [openharmony]
+
+ '@esbuild/sunos-x64@0.25.12':
+ resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [sunos]
+
+ '@esbuild/win32-arm64@0.25.12':
+ resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@esbuild/win32-ia32@0.25.12':
+ resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==}
+ engines: {node: '>=18'}
+ cpu: [ia32]
+ os: [win32]
+
+ '@esbuild/win32-x64@0.25.12':
+ resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [win32]
+
+ '@eslint-community/eslint-utils@4.9.0':
+ resolution: {integrity: sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==}
+ 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.21.1':
+ resolution: {integrity: sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@eslint/config-helpers@0.4.2':
+ resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@eslint/core@0.17.0':
+ resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@eslint/eslintrc@3.3.1':
+ resolution: {integrity: sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@eslint/js@9.39.1':
+ resolution: {integrity: sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@eslint/object-schema@2.1.7':
+ resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@eslint/plugin-kit@0.4.1':
+ resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@floating-ui/core@1.7.3':
+ resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==}
+
+ '@floating-ui/dom@1.7.4':
+ resolution: {integrity: sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==}
+
+ '@floating-ui/react-dom@2.1.6':
+ resolution: {integrity: sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==}
+ 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]
+
+ '@img/sharp-libvips-linux-arm@1.2.4':
+ resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
+ cpu: [arm]
+ os: [linux]
+
+ '@img/sharp-libvips-linux-ppc64@1.2.4':
+ resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@img/sharp-libvips-linux-riscv64@1.2.4':
+ resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@img/sharp-libvips-linux-s390x@1.2.4':
+ resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
+ cpu: [s390x]
+ os: [linux]
+
+ '@img/sharp-libvips-linux-x64@1.2.4':
+ resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
+ cpu: [x64]
+ os: [linux]
+
+ '@img/sharp-libvips-linuxmusl-arm64@1.2.4':
+ resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
+ cpu: [arm64]
+ os: [linux]
+
+ '@img/sharp-libvips-linuxmusl-x64@1.2.4':
+ resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
+ cpu: [x64]
+ os: [linux]
+
+ '@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]
+
+ '@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]
+
+ '@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]
+
+ '@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]
+
+ '@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]
+
+ '@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]
+
+ '@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]
+
+ '@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]
+
+ '@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==}
+
+ '@nodelib/fs.scandir@2.1.5':
+ resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
+ engines: {node: '>= 8'}
+
+ '@nodelib/fs.stat@2.0.5':
+ resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==}
+ engines: {node: '>= 8'}
+
+ '@nodelib/fs.walk@1.2.8':
+ resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
+ engines: {node: '>= 8'}
+
+ '@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-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-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-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@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-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-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.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-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.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-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-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-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-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-beta.47':
+ resolution: {integrity: sha512-8QagwMH3kNCuzD8EWL8R2YPW5e4OrHNSAHRFDdmFqEwEaD/KcNKjVoumo+gP2vW5eKB2UPbM6vTYiGZX0ixLnw==}
+
+ '@rollup/rollup-android-arm-eabi@4.53.3':
+ resolution: {integrity: sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==}
+ cpu: [arm]
+ os: [android]
+
+ '@rollup/rollup-android-arm64@4.53.3':
+ resolution: {integrity: sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==}
+ cpu: [arm64]
+ os: [android]
+
+ '@rollup/rollup-darwin-arm64@4.53.3':
+ resolution: {integrity: sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@rollup/rollup-darwin-x64@4.53.3':
+ resolution: {integrity: sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==}
+ cpu: [x64]
+ os: [darwin]
+
+ '@rollup/rollup-freebsd-arm64@4.53.3':
+ resolution: {integrity: sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==}
+ cpu: [arm64]
+ os: [freebsd]
+
+ '@rollup/rollup-freebsd-x64@4.53.3':
+ resolution: {integrity: sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@rollup/rollup-linux-arm-gnueabihf@4.53.3':
+ resolution: {integrity: sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==}
+ cpu: [arm]
+ os: [linux]
+
+ '@rollup/rollup-linux-arm-musleabihf@4.53.3':
+ resolution: {integrity: sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==}
+ cpu: [arm]
+ os: [linux]
+
+ '@rollup/rollup-linux-arm64-gnu@4.53.3':
+ resolution: {integrity: sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==}
+ cpu: [arm64]
+ os: [linux]
+
+ '@rollup/rollup-linux-arm64-musl@4.53.3':
+ resolution: {integrity: sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==}
+ cpu: [arm64]
+ os: [linux]
+
+ '@rollup/rollup-linux-loong64-gnu@4.53.3':
+ resolution: {integrity: sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==}
+ cpu: [loong64]
+ os: [linux]
+
+ '@rollup/rollup-linux-ppc64-gnu@4.53.3':
+ resolution: {integrity: sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@rollup/rollup-linux-riscv64-gnu@4.53.3':
+ resolution: {integrity: sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@rollup/rollup-linux-riscv64-musl@4.53.3':
+ resolution: {integrity: sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@rollup/rollup-linux-s390x-gnu@4.53.3':
+ resolution: {integrity: sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==}
+ cpu: [s390x]
+ os: [linux]
+
+ '@rollup/rollup-linux-x64-gnu@4.53.3':
+ resolution: {integrity: sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==}
+ cpu: [x64]
+ os: [linux]
+
+ '@rollup/rollup-linux-x64-musl@4.53.3':
+ resolution: {integrity: sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==}
+ cpu: [x64]
+ os: [linux]
+
+ '@rollup/rollup-openharmony-arm64@4.53.3':
+ resolution: {integrity: sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==}
+ cpu: [arm64]
+ os: [openharmony]
+
+ '@rollup/rollup-win32-arm64-msvc@4.53.3':
+ resolution: {integrity: sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==}
+ cpu: [arm64]
+ os: [win32]
+
+ '@rollup/rollup-win32-ia32-msvc@4.53.3':
+ resolution: {integrity: sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==}
+ cpu: [ia32]
+ os: [win32]
+
+ '@rollup/rollup-win32-x64-gnu@4.53.3':
+ resolution: {integrity: sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==}
+ cpu: [x64]
+ os: [win32]
+
+ '@rollup/rollup-win32-x64-msvc@4.53.3':
+ resolution: {integrity: sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==}
+ cpu: [x64]
+ os: [win32]
+
+ '@tailwindcss/node@4.1.17':
+ resolution: {integrity: sha512-csIkHIgLb3JisEFQ0vxr2Y57GUNYh447C8xzwj89U/8fdW8LhProdxvnVH6U8M2Y73QKiTIH+LWbK3V2BBZsAg==}
+
+ '@tailwindcss/oxide-android-arm64@4.1.17':
+ resolution: {integrity: sha512-BMqpkJHgOZ5z78qqiGE6ZIRExyaHyuxjgrJ6eBO5+hfrfGkuya0lYfw8fRHG77gdTjWkNWEEm+qeG2cDMxArLQ==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [android]
+
+ '@tailwindcss/oxide-darwin-arm64@4.1.17':
+ resolution: {integrity: sha512-EquyumkQweUBNk1zGEU/wfZo2qkp/nQKRZM8bUYO0J+Lums5+wl2CcG1f9BgAjn/u9pJzdYddHWBiFXJTcxmOg==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@tailwindcss/oxide-darwin-x64@4.1.17':
+ resolution: {integrity: sha512-gdhEPLzke2Pog8s12oADwYu0IAw04Y2tlmgVzIN0+046ytcgx8uZmCzEg4VcQh+AHKiS7xaL8kGo/QTiNEGRog==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@tailwindcss/oxide-freebsd-x64@4.1.17':
+ resolution: {integrity: sha512-hxGS81KskMxML9DXsaXT1H0DyA+ZBIbyG/sSAjWNe2EDl7TkPOBI42GBV3u38itzGUOmFfCzk1iAjDXds8Oh0g==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.17':
+ resolution: {integrity: sha512-k7jWk5E3ldAdw0cNglhjSgv501u7yrMf8oeZ0cElhxU6Y2o7f8yqelOp3fhf7evjIS6ujTI3U8pKUXV2I4iXHQ==}
+ engines: {node: '>= 10'}
+ cpu: [arm]
+ os: [linux]
+
+ '@tailwindcss/oxide-linux-arm64-gnu@4.1.17':
+ resolution: {integrity: sha512-HVDOm/mxK6+TbARwdW17WrgDYEGzmoYayrCgmLEw7FxTPLcp/glBisuyWkFz/jb7ZfiAXAXUACfyItn+nTgsdQ==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@tailwindcss/oxide-linux-arm64-musl@4.1.17':
+ resolution: {integrity: sha512-HvZLfGr42i5anKtIeQzxdkw/wPqIbpeZqe7vd3V9vI3RQxe3xU1fLjss0TjyhxWcBaipk7NYwSrwTwK1hJARMg==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@tailwindcss/oxide-linux-x64-gnu@4.1.17':
+ resolution: {integrity: sha512-M3XZuORCGB7VPOEDH+nzpJ21XPvK5PyjlkSFkFziNHGLc5d6g3di2McAAblmaSUNl8IOmzYwLx9NsE7bplNkwQ==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [linux]
+
+ '@tailwindcss/oxide-linux-x64-musl@4.1.17':
+ resolution: {integrity: sha512-k7f+pf9eXLEey4pBlw+8dgfJHY4PZ5qOUFDyNf7SI6lHjQ9Zt7+NcscjpwdCEbYi6FI5c2KDTDWyf2iHcCSyyQ==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [linux]
+
+ '@tailwindcss/oxide-wasm32-wasi@4.1.17':
+ resolution: {integrity: sha512-cEytGqSSoy7zK4JRWiTCx43FsKP/zGr0CsuMawhH67ONlH+T79VteQeJQRO/X7L0juEUA8ZyuYikcRBf0vsxhg==}
+ 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.1.17':
+ resolution: {integrity: sha512-JU5AHr7gKbZlOGvMdb4722/0aYbU+tN6lv1kONx0JK2cGsh7g148zVWLM0IKR3NeKLv+L90chBVYcJ8uJWbC9A==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@tailwindcss/oxide-win32-x64-msvc@4.1.17':
+ resolution: {integrity: sha512-SKWM4waLuqx0IH+FMDUw6R66Hu4OuTALFgnleKbqhgGU30DY20NORZMZUKgLRjQXNN2TLzKvh48QXTig4h4bGw==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [win32]
+
+ '@tailwindcss/oxide@4.1.17':
+ resolution: {integrity: sha512-F0F7d01fmkQhsTjXezGBLdrl1KresJTcI3DB8EkScCldyKp3Msz4hub4uyYaVnk88BAS1g5DQjjF6F5qczheLA==}
+ engines: {node: '>= 10'}
+
+ '@tailwindcss/vite@4.1.17':
+ resolution: {integrity: sha512-4+9w8ZHOiGnpcGI6z1TVVfWaX/koK7fKeSYF3qlYg2xpBtbteP2ddBxiarL+HVgfSJGeK5RIxRQmKm4rTJJAwA==}
+ 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/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@24.10.1':
+ resolution: {integrity: sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==}
+
+ '@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.6':
+ resolution: {integrity: sha512-p/jUvulfgU7oKtj6Xpk8cA2Y1xKTtICGpJYeJXz2YVO2UcvjQgeRMLDGfDeqeRW2Ta+0QNFwcc8X3GH8SxZz6w==}
+
+ '@typescript-eslint/eslint-plugin@8.47.0':
+ resolution: {integrity: sha512-fe0rz9WJQ5t2iaLfdbDc9T80GJy0AeO453q8C3YCilnGozvOyCG5t+EZtg7j7D88+c3FipfP/x+wzGnh1xp8ZA==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ '@typescript-eslint/parser': ^8.47.0
+ eslint: ^8.57.0 || ^9.0.0
+ typescript: '>=4.8.4 <6.0.0'
+
+ '@typescript-eslint/parser@8.47.0':
+ resolution: {integrity: sha512-lJi3PfxVmo0AkEY93ecfN+r8SofEqZNGByvHAI3GBLrvt1Cw6H5k1IM02nSzu0RfUafr2EvFSw0wAsZgubNplQ==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ eslint: ^8.57.0 || ^9.0.0
+ typescript: '>=4.8.4 <6.0.0'
+
+ '@typescript-eslint/project-service@8.47.0':
+ resolution: {integrity: sha512-2X4BX8hUeB5JcA1TQJ7GjcgulXQ+5UkNb0DL8gHsHUHdFoiCTJoYLTpib3LtSDPZsRET5ygN4qqIWrHyYIKERA==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ typescript: '>=4.8.4 <6.0.0'
+
+ '@typescript-eslint/scope-manager@8.47.0':
+ resolution: {integrity: sha512-a0TTJk4HXMkfpFkL9/WaGTNuv7JWfFTQFJd6zS9dVAjKsojmv9HT55xzbEpnZoY+VUb+YXLMp+ihMLz/UlZfDg==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@typescript-eslint/tsconfig-utils@8.47.0':
+ resolution: {integrity: sha512-ybUAvjy4ZCL11uryalkKxuT3w3sXJAuWhOoGS3T/Wu+iUu1tGJmk5ytSY8gbdACNARmcYEB0COksD2j6hfGK2g==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ typescript: '>=4.8.4 <6.0.0'
+
+ '@typescript-eslint/type-utils@8.47.0':
+ resolution: {integrity: sha512-QC9RiCmZ2HmIdCEvhd1aJELBlD93ErziOXXlHEZyuBo3tBiAZieya0HLIxp+DoDWlsQqDawyKuNEhORyku+P8A==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ eslint: ^8.57.0 || ^9.0.0
+ typescript: '>=4.8.4 <6.0.0'
+
+ '@typescript-eslint/types@8.47.0':
+ resolution: {integrity: sha512-nHAE6bMKsizhA2uuYZbEbmp5z2UpffNrPEqiKIeN7VsV6UY/roxanWfoRrf6x/k9+Obf+GQdkm0nPU+vnMXo9A==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@typescript-eslint/typescript-estree@8.47.0':
+ resolution: {integrity: sha512-k6ti9UepJf5NpzCjH31hQNLHQWupTRPhZ+KFF8WtTuTpy7uHPfeg2NM7cP27aCGajoEplxJDFVCEm9TGPYyiVg==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ typescript: '>=4.8.4 <6.0.0'
+
+ '@typescript-eslint/utils@8.47.0':
+ resolution: {integrity: sha512-g7XrNf25iL4TJOiPqatNuaChyqt49a/onq5YsJ9+hXeugK+41LVg7AxikMfM02PC6jbNtZLCJj6AUcQXJS/jGQ==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ eslint: ^8.57.0 || ^9.0.0
+ typescript: '>=4.8.4 <6.0.0'
+
+ '@typescript-eslint/visitor-keys@8.47.0':
+ resolution: {integrity: sha512-SIV3/6eftCy1bNzCQoPmbWsRLujS8t5iDIZ4spZOBHqrM+yfX2ogg8Tt3PDTAVKw3sSCiUgg30uOAvK2r9zGjQ==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@vitejs/plugin-react@5.1.1':
+ resolution: {integrity: sha512-WQfkSw0QbQ5aJ2CHYw23ZGkqnRwqKHD/KYsMeTkZzPT4Jcf0DcBxBtwMJxnu6E7oxw5+JC6ZAiePgh28uJ1HBA==}
+ 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.15.0:
+ resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==}
+ engines: {node: '>=0.4.0'}
+ hasBin: true
+
+ ajv@6.12.6:
+ resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==}
+
+ ansi-styles@4.3.0:
+ resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
+ engines: {node: '>=8'}
+
+ argparse@2.0.1:
+ resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
+
+ aria-hidden@1.2.6:
+ resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
+ engines: {node: '>=10'}
+
+ balanced-match@1.0.2:
+ resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
+
+ baseline-browser-mapping@2.8.30:
+ resolution: {integrity: sha512-aTUKW4ptQhS64+v2d6IkPzymEzzhw+G0bA1g3uBRV3+ntkH+svttKseW5IOR4Ed6NUVKqnY7qT3dKvzQ7io4AA==}
+ hasBin: true
+
+ brace-expansion@1.1.12:
+ resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
+
+ brace-expansion@2.0.2:
+ resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==}
+
+ braces@3.0.3:
+ resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
+ engines: {node: '>=8'}
+
+ browserslist@4.28.0:
+ resolution: {integrity: sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ==}
+ engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
+ hasBin: true
+
+ callsites@3.1.0:
+ resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
+ engines: {node: '>=6'}
+
+ caniuse-lite@1.0.30001756:
+ resolution: {integrity: sha512-4HnCNKbMLkLdhJz3TToeVWHSnfJvPaq6vu/eRP0Ahub/07n484XHhBF5AJoSGHdVrS8tKFauUQz8Bp9P7LVx7A==}
+
+ chalk@4.1.2:
+ resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
+ engines: {node: '>=10'}
+
+ 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'}
+
+ color-convert@2.0.1:
+ resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
+ engines: {node: '>=7.0.0'}
+
+ color-name@1.1.4:
+ resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
+
+ concat-map@0.0.1:
+ resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
+
+ 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.259:
+ resolution: {integrity: sha512-I+oLXgpEJzD6Cwuwt1gYjxsDmu/S/Kd41mmLA3O+/uH2pFRO/DvOjUyGozL8j3KeLV6WyZ7ssPwELMsXCcsJAQ==}
+
+ enhanced-resolve@5.18.3:
+ resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==}
+ engines: {node: '>=10.13.0'}
+
+ esbuild@0.25.12:
+ resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==}
+ 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.4.24:
+ resolution: {integrity: sha512-nLHIW7TEq3aLrEYWpVaJ1dRgFR+wLDPN8e8FpYAql/bMV2oBEfC37K0gLEGgv9fy66juNShSMV8OkTqzltcG/w==}
+ peerDependencies:
+ eslint: '>=8.40'
+
+ eslint-scope@8.4.0:
+ resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ 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@4.2.1:
+ resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ eslint@9.39.1:
+ resolution: {integrity: sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ hasBin: true
+ peerDependencies:
+ jiti: '*'
+ peerDependenciesMeta:
+ jiti:
+ optional: true
+
+ espree@10.4.0:
+ resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ esquery@1.6.0:
+ resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==}
+ 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-glob@3.3.3:
+ resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
+ engines: {node: '>=8.6.0'}
+
+ fast-json-stable-stringify@2.1.0:
+ resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==}
+
+ fast-levenshtein@2.0.6:
+ resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
+
+ fastq@1.19.1:
+ resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==}
+
+ 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'}
+
+ fill-range@7.1.1:
+ resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
+ engines: {node: '>=8'}
+
+ 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==}
+
+ 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@5.1.2:
+ resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
+ engines: {node: '>= 6'}
+
+ glob-parent@6.0.2:
+ resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
+ engines: {node: '>=10.13.0'}
+
+ globals@14.0.0:
+ resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==}
+ engines: {node: '>=18'}
+
+ globals@16.5.0:
+ resolution: {integrity: sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==}
+ engines: {node: '>=18'}
+
+ graceful-fs@4.2.11:
+ resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
+
+ graphemer@1.4.0:
+ resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==}
+
+ has-flag@4.0.0:
+ resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
+ engines: {node: '>=8'}
+
+ 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'}
+
+ import-fresh@3.3.1:
+ resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
+ engines: {node: '>=6'}
+
+ 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'}
+
+ is-number@7.0.0:
+ resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
+ engines: {node: '>=0.12.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==}
+
+ js-yaml@4.1.1:
+ resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
+ hasBin: true
+
+ 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.30.2:
+ resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [android]
+
+ lightningcss-darwin-arm64@1.30.2:
+ resolution: {integrity: sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [darwin]
+
+ lightningcss-darwin-x64@1.30.2:
+ resolution: {integrity: sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [darwin]
+
+ lightningcss-freebsd-x64@1.30.2:
+ resolution: {integrity: sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [freebsd]
+
+ lightningcss-linux-arm-gnueabihf@1.30.2:
+ resolution: {integrity: sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm]
+ os: [linux]
+
+ lightningcss-linux-arm64-gnu@1.30.2:
+ resolution: {integrity: sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [linux]
+
+ lightningcss-linux-arm64-musl@1.30.2:
+ resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [linux]
+
+ lightningcss-linux-x64-gnu@1.30.2:
+ resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [linux]
+
+ lightningcss-linux-x64-musl@1.30.2:
+ resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [linux]
+
+ lightningcss-win32-arm64-msvc@1.30.2:
+ resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [win32]
+
+ lightningcss-win32-x64-msvc@1.30.2:
+ resolution: {integrity: sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [win32]
+
+ lightningcss@1.30.2:
+ resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==}
+ engines: {node: '>= 12.0.0'}
+
+ locate-path@6.0.0:
+ resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
+ engines: {node: '>=10'}
+
+ lodash.merge@4.6.2:
+ resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
+
+ lru-cache@5.1.1:
+ resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
+
+ lucide-react@0.554.0:
+ resolution: {integrity: sha512-St+z29uthEJVx0Is7ellNkgTEhaeSoA42I7JjOCBCrc5X6LYMGSv0P/2uS5HDLTExP5tpiqRD2PyUEOS6s9UXA==}
+ 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==}
+
+ merge2@1.4.1:
+ resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
+ engines: {node: '>= 8'}
+
+ micromatch@4.0.8:
+ resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
+ engines: {node: '>=8.6'}
+
+ minimatch@3.1.2:
+ resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
+
+ minimatch@9.0.5:
+ resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==}
+ engines: {node: '>=16 || 14 >=14.17'}
+
+ 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'}
+
+ parent-module@1.0.1:
+ resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
+ engines: {node: '>=6'}
+
+ 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@2.3.1:
+ resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
+ engines: {node: '>=8.6'}
+
+ 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'}
+
+ queue-microtask@1.2.3:
+ resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
+
+ react-dom@19.2.0:
+ resolution: {integrity: sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==}
+ peerDependencies:
+ react: ^19.2.0
+
+ 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.1:
+ resolution: {integrity: sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA==}
+ 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.0:
+ resolution: {integrity: sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==}
+ engines: {node: '>=0.10.0'}
+
+ resolve-from@4.0.0:
+ resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
+ engines: {node: '>=4'}
+
+ reusify@1.1.0:
+ resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
+ engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
+
+ rollup@4.53.3:
+ resolution: {integrity: sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==}
+ engines: {node: '>=18.0.0', npm: '>=8.0.0'}
+ hasBin: true
+
+ run-parallel@1.2.0:
+ resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
+
+ 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.3:
+ resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==}
+ 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'}
+
+ strip-json-comments@3.1.1:
+ resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
+ engines: {node: '>=8'}
+
+ supports-color@7.2.0:
+ resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
+ engines: {node: '>=8'}
+
+ tailwind-merge@3.4.0:
+ resolution: {integrity: sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==}
+
+ tailwindcss@4.1.17:
+ resolution: {integrity: sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==}
+
+ 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'}
+
+ to-regex-range@5.0.1:
+ resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
+ engines: {node: '>=8.0'}
+
+ ts-api-utils@2.1.0:
+ resolution: {integrity: sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==}
+ 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.47.0:
+ resolution: {integrity: sha512-Lwe8i2XQ3WoMjua/r1PHrCTpkubPYJCAfOurtn+mtTzqB6jNd+14n9UN1bJ4s3F49x9ixAm0FLflB/JzQ57M8Q==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ eslint: ^8.57.0 || ^9.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.16.0:
+ resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
+
+ update-browserslist-db@1.1.4:
+ resolution: {integrity: sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==}
+ 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
+
+ vite@7.2.4:
+ resolution: {integrity: sha512-NL8jTlbo0Tn4dUEXEsUg8KeyG/Lkmc4Fnzb8JXN/Ykm9G4HNImjtABMJgkQoVjOBN/j2WAwDTRytdqJbZsah7w==}
+ 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.1.12:
+ resolution: {integrity: sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==}
+
+snapshots:
+
+ '@babel/code-frame@7.27.1':
+ dependencies:
+ '@babel/helper-validator-identifier': 7.28.5
+ js-tokens: 4.0.0
+ picocolors: 1.1.1
+
+ '@babel/compat-data@7.28.5': {}
+
+ '@babel/core@7.28.5':
+ dependencies:
+ '@babel/code-frame': 7.27.1
+ '@babel/generator': 7.28.5
+ '@babel/helper-compilation-targets': 7.27.2
+ '@babel/helper-module-transforms': 7.28.3(@babel/core@7.28.5)
+ '@babel/helpers': 7.28.4
+ '@babel/parser': 7.28.5
+ '@babel/template': 7.27.2
+ '@babel/traverse': 7.28.5
+ '@babel/types': 7.28.5
+ '@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.28.5':
+ dependencies:
+ '@babel/parser': 7.28.5
+ '@babel/types': 7.28.5
+ '@jridgewell/gen-mapping': 0.3.13
+ '@jridgewell/trace-mapping': 0.3.31
+ jsesc: 3.1.0
+
+ '@babel/helper-compilation-targets@7.27.2':
+ dependencies:
+ '@babel/compat-data': 7.28.5
+ '@babel/helper-validator-option': 7.27.1
+ browserslist: 4.28.0
+ lru-cache: 5.1.1
+ semver: 6.3.1
+
+ '@babel/helper-globals@7.28.0': {}
+
+ '@babel/helper-module-imports@7.27.1':
+ dependencies:
+ '@babel/traverse': 7.28.5
+ '@babel/types': 7.28.5
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/helper-module-transforms@7.28.3(@babel/core@7.28.5)':
+ dependencies:
+ '@babel/core': 7.28.5
+ '@babel/helper-module-imports': 7.27.1
+ '@babel/helper-validator-identifier': 7.28.5
+ '@babel/traverse': 7.28.5
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/helper-plugin-utils@7.27.1': {}
+
+ '@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.4':
+ dependencies:
+ '@babel/template': 7.27.2
+ '@babel/types': 7.28.5
+
+ '@babel/parser@7.28.5':
+ dependencies:
+ '@babel/types': 7.28.5
+
+ '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.28.5)':
+ dependencies:
+ '@babel/core': 7.28.5
+ '@babel/helper-plugin-utils': 7.27.1
+
+ '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.28.5)':
+ dependencies:
+ '@babel/core': 7.28.5
+ '@babel/helper-plugin-utils': 7.27.1
+
+ '@babel/template@7.27.2':
+ dependencies:
+ '@babel/code-frame': 7.27.1
+ '@babel/parser': 7.28.5
+ '@babel/types': 7.28.5
+
+ '@babel/traverse@7.28.5':
+ dependencies:
+ '@babel/code-frame': 7.27.1
+ '@babel/generator': 7.28.5
+ '@babel/helper-globals': 7.28.0
+ '@babel/parser': 7.28.5
+ '@babel/template': 7.27.2
+ '@babel/types': 7.28.5
+ debug: 4.4.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/types@7.28.5':
+ dependencies:
+ '@babel/helper-string-parser': 7.27.1
+ '@babel/helper-validator-identifier': 7.28.5
+
+ '@emnapi/runtime@1.7.1':
+ dependencies:
+ tslib: 2.8.1
+ optional: true
+
+ '@esbuild/aix-ppc64@0.25.12':
+ optional: true
+
+ '@esbuild/android-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/android-arm@0.25.12':
+ optional: true
+
+ '@esbuild/android-x64@0.25.12':
+ optional: true
+
+ '@esbuild/darwin-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/darwin-x64@0.25.12':
+ optional: true
+
+ '@esbuild/freebsd-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/freebsd-x64@0.25.12':
+ optional: true
+
+ '@esbuild/linux-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/linux-arm@0.25.12':
+ optional: true
+
+ '@esbuild/linux-ia32@0.25.12':
+ optional: true
+
+ '@esbuild/linux-loong64@0.25.12':
+ optional: true
+
+ '@esbuild/linux-mips64el@0.25.12':
+ optional: true
+
+ '@esbuild/linux-ppc64@0.25.12':
+ optional: true
+
+ '@esbuild/linux-riscv64@0.25.12':
+ optional: true
+
+ '@esbuild/linux-s390x@0.25.12':
+ optional: true
+
+ '@esbuild/linux-x64@0.25.12':
+ optional: true
+
+ '@esbuild/netbsd-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/netbsd-x64@0.25.12':
+ optional: true
+
+ '@esbuild/openbsd-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/openbsd-x64@0.25.12':
+ optional: true
+
+ '@esbuild/openharmony-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/sunos-x64@0.25.12':
+ optional: true
+
+ '@esbuild/win32-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/win32-ia32@0.25.12':
+ optional: true
+
+ '@esbuild/win32-x64@0.25.12':
+ optional: true
+
+ '@eslint-community/eslint-utils@4.9.0(eslint@9.39.1(jiti@2.6.1))':
+ dependencies:
+ eslint: 9.39.1(jiti@2.6.1)
+ eslint-visitor-keys: 3.4.3
+
+ '@eslint-community/regexpp@4.12.2': {}
+
+ '@eslint/config-array@0.21.1':
+ dependencies:
+ '@eslint/object-schema': 2.1.7
+ debug: 4.4.3
+ minimatch: 3.1.2
+ transitivePeerDependencies:
+ - supports-color
+
+ '@eslint/config-helpers@0.4.2':
+ dependencies:
+ '@eslint/core': 0.17.0
+
+ '@eslint/core@0.17.0':
+ dependencies:
+ '@types/json-schema': 7.0.15
+
+ '@eslint/eslintrc@3.3.1':
+ dependencies:
+ ajv: 6.12.6
+ debug: 4.4.3
+ espree: 10.4.0
+ globals: 14.0.0
+ ignore: 5.3.2
+ import-fresh: 3.3.1
+ js-yaml: 4.1.1
+ minimatch: 3.1.2
+ strip-json-comments: 3.1.1
+ transitivePeerDependencies:
+ - supports-color
+
+ '@eslint/js@9.39.1': {}
+
+ '@eslint/object-schema@2.1.7': {}
+
+ '@eslint/plugin-kit@0.4.1':
+ dependencies:
+ '@eslint/core': 0.17.0
+ levn: 0.4.1
+
+ '@floating-ui/core@1.7.3':
+ dependencies:
+ '@floating-ui/utils': 0.2.10
+
+ '@floating-ui/dom@1.7.4':
+ dependencies:
+ '@floating-ui/core': 1.7.3
+ '@floating-ui/utils': 0.2.10
+
+ '@floating-ui/react-dom@2.1.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
+ dependencies:
+ '@floating-ui/dom': 1.7.4
+ react: 19.2.0
+ react-dom: 19.2.0(react@19.2.0)
+
+ '@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.7.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
+
+ '@nodelib/fs.scandir@2.1.5':
+ dependencies:
+ '@nodelib/fs.stat': 2.0.5
+ run-parallel: 1.2.0
+
+ '@nodelib/fs.stat@2.0.5': {}
+
+ '@nodelib/fs.walk@1.2.8':
+ dependencies:
+ '@nodelib/fs.scandir': 2.1.5
+ fastq: 1.19.1
+
+ '@radix-ui/number@1.1.1': {}
+
+ '@radix-ui/primitive@1.1.3': {}
+
+ '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
+ dependencies:
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ react: 19.2.0
+ react-dom: 19.2.0(react@19.2.0)
+ optionalDependencies:
+ '@types/react': 19.2.6
+ '@types/react-dom': 19.2.3(@types/react@19.2.6)
+
+ '@radix-ui/react-checkbox@1.3.3(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.6)(react@19.2.0)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.6)(react@19.2.0)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.6)(react@19.2.0)
+ '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.6)(react@19.2.0)
+ '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.6)(react@19.2.0)
+ react: 19.2.0
+ react-dom: 19.2.0(react@19.2.0)
+ optionalDependencies:
+ '@types/react': 19.2.6
+ '@types/react-dom': 19.2.3(@types/react@19.2.6)
+
+ '@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
+ dependencies:
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.6)(react@19.2.0)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.6)(react@19.2.0)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@radix-ui/react-slot': 1.2.3(@types/react@19.2.6)(react@19.2.0)
+ react: 19.2.0
+ react-dom: 19.2.0(react@19.2.0)
+ optionalDependencies:
+ '@types/react': 19.2.6
+ '@types/react-dom': 19.2.3(@types/react@19.2.6)
+
+ '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.6)(react@19.2.0)':
+ dependencies:
+ react: 19.2.0
+ optionalDependencies:
+ '@types/react': 19.2.6
+
+ '@radix-ui/react-context@1.1.2(@types/react@19.2.6)(react@19.2.0)':
+ dependencies:
+ react: 19.2.0
+ optionalDependencies:
+ '@types/react': 19.2.6
+
+ '@radix-ui/react-context@1.1.3(@types/react@19.2.6)(react@19.2.0)':
+ dependencies:
+ react: 19.2.0
+ optionalDependencies:
+ '@types/react': 19.2.6
+
+ '@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.6)(react@19.2.0)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.6)(react@19.2.0)
+ '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.6)(react@19.2.0)
+ '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@radix-ui/react-id': 1.1.1(@types/react@19.2.6)(react@19.2.0)
+ '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@radix-ui/react-slot': 1.2.3(@types/react@19.2.6)(react@19.2.0)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.6)(react@19.2.0)
+ aria-hidden: 1.2.6
+ react: 19.2.0
+ react-dom: 19.2.0(react@19.2.0)
+ react-remove-scroll: 2.7.1(@types/react@19.2.6)(react@19.2.0)
+ optionalDependencies:
+ '@types/react': 19.2.6
+ '@types/react-dom': 19.2.3(@types/react@19.2.6)
+
+ '@radix-ui/react-direction@1.1.1(@types/react@19.2.6)(react@19.2.0)':
+ dependencies:
+ react: 19.2.0
+ optionalDependencies:
+ '@types/react': 19.2.6
+
+ '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.6)(react@19.2.0)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.6)(react@19.2.0)
+ '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.6)(react@19.2.0)
+ react: 19.2.0
+ react-dom: 19.2.0(react@19.2.0)
+ optionalDependencies:
+ '@types/react': 19.2.6
+ '@types/react-dom': 19.2.3(@types/react@19.2.6)
+
+ '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.6)(react@19.2.0)':
+ dependencies:
+ react: 19.2.0
+ optionalDependencies:
+ '@types/react': 19.2.6
+
+ '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
+ dependencies:
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.6)(react@19.2.0)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.6)(react@19.2.0)
+ react: 19.2.0
+ react-dom: 19.2.0(react@19.2.0)
+ optionalDependencies:
+ '@types/react': 19.2.6
+ '@types/react-dom': 19.2.3(@types/react@19.2.6)
+
+ '@radix-ui/react-id@1.1.1(@types/react@19.2.6)(react@19.2.0)':
+ dependencies:
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.6)(react@19.2.0)
+ react: 19.2.0
+ optionalDependencies:
+ '@types/react': 19.2.6
+
+ '@radix-ui/react-label@2.1.8(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
+ dependencies:
+ '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ react: 19.2.0
+ react-dom: 19.2.0(react@19.2.0)
+ optionalDependencies:
+ '@types/react': 19.2.6
+ '@types/react-dom': 19.2.3(@types/react@19.2.6)
+
+ '@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
+ dependencies:
+ '@floating-ui/react-dom': 2.1.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.6)(react@19.2.0)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.6)(react@19.2.0)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.6)(react@19.2.0)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.6)(react@19.2.0)
+ '@radix-ui/react-use-rect': 1.1.1(@types/react@19.2.6)(react@19.2.0)
+ '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.6)(react@19.2.0)
+ '@radix-ui/rect': 1.1.1
+ react: 19.2.0
+ react-dom: 19.2.0(react@19.2.0)
+ optionalDependencies:
+ '@types/react': 19.2.6
+ '@types/react-dom': 19.2.3(@types/react@19.2.6)
+
+ '@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
+ dependencies:
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.6)(react@19.2.0)
+ react: 19.2.0
+ react-dom: 19.2.0(react@19.2.0)
+ optionalDependencies:
+ '@types/react': 19.2.6
+ '@types/react-dom': 19.2.3(@types/react@19.2.6)
+
+ '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
+ dependencies:
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.6)(react@19.2.0)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.6)(react@19.2.0)
+ react: 19.2.0
+ react-dom: 19.2.0(react@19.2.0)
+ optionalDependencies:
+ '@types/react': 19.2.6
+ '@types/react-dom': 19.2.3(@types/react@19.2.6)
+
+ '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
+ dependencies:
+ '@radix-ui/react-slot': 1.2.3(@types/react@19.2.6)(react@19.2.0)
+ react: 19.2.0
+ react-dom: 19.2.0(react@19.2.0)
+ optionalDependencies:
+ '@types/react': 19.2.6
+ '@types/react-dom': 19.2.3(@types/react@19.2.6)
+
+ '@radix-ui/react-primitive@2.1.4(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
+ dependencies:
+ '@radix-ui/react-slot': 1.2.4(@types/react@19.2.6)(react@19.2.0)
+ react: 19.2.0
+ react-dom: 19.2.0(react@19.2.0)
+ optionalDependencies:
+ '@types/react': 19.2.6
+ '@types/react-dom': 19.2.3(@types/react@19.2.6)
+
+ '@radix-ui/react-progress@1.1.8(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
+ dependencies:
+ '@radix-ui/react-context': 1.1.3(@types/react@19.2.6)(react@19.2.0)
+ '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ react: 19.2.0
+ react-dom: 19.2.0(react@19.2.0)
+ optionalDependencies:
+ '@types/react': 19.2.6
+ '@types/react-dom': 19.2.3(@types/react@19.2.6)
+
+ '@radix-ui/react-radio-group@1.3.8(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.6)(react@19.2.0)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.6)(react@19.2.0)
+ '@radix-ui/react-direction': 1.1.1(@types/react@19.2.6)(react@19.2.0)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.6)(react@19.2.0)
+ '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.6)(react@19.2.0)
+ '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.6)(react@19.2.0)
+ react: 19.2.0
+ react-dom: 19.2.0(react@19.2.0)
+ optionalDependencies:
+ '@types/react': 19.2.6
+ '@types/react-dom': 19.2.3(@types/react@19.2.6)
+
+ '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.6)(react@19.2.0)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.6)(react@19.2.0)
+ '@radix-ui/react-direction': 1.1.1(@types/react@19.2.6)(react@19.2.0)
+ '@radix-ui/react-id': 1.1.1(@types/react@19.2.6)(react@19.2.0)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.6)(react@19.2.0)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.6)(react@19.2.0)
+ react: 19.2.0
+ react-dom: 19.2.0(react@19.2.0)
+ optionalDependencies:
+ '@types/react': 19.2.6
+ '@types/react-dom': 19.2.3(@types/react@19.2.6)
+
+ '@radix-ui/react-select@2.2.6(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
+ 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.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.6)(react@19.2.0)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.6)(react@19.2.0)
+ '@radix-ui/react-direction': 1.1.1(@types/react@19.2.6)(react@19.2.0)
+ '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.6)(react@19.2.0)
+ '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@radix-ui/react-id': 1.1.1(@types/react@19.2.6)(react@19.2.0)
+ '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@radix-ui/react-slot': 1.2.3(@types/react@19.2.6)(react@19.2.0)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.6)(react@19.2.0)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.6)(react@19.2.0)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.6)(react@19.2.0)
+ '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.6)(react@19.2.0)
+ '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ aria-hidden: 1.2.6
+ react: 19.2.0
+ react-dom: 19.2.0(react@19.2.0)
+ react-remove-scroll: 2.7.1(@types/react@19.2.6)(react@19.2.0)
+ optionalDependencies:
+ '@types/react': 19.2.6
+ '@types/react-dom': 19.2.3(@types/react@19.2.6)
+
+ '@radix-ui/react-slot@1.2.3(@types/react@19.2.6)(react@19.2.0)':
+ dependencies:
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.6)(react@19.2.0)
+ react: 19.2.0
+ optionalDependencies:
+ '@types/react': 19.2.6
+
+ '@radix-ui/react-slot@1.2.4(@types/react@19.2.6)(react@19.2.0)':
+ dependencies:
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.6)(react@19.2.0)
+ react: 19.2.0
+ optionalDependencies:
+ '@types/react': 19.2.6
+
+ '@radix-ui/react-switch@1.2.6(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.6)(react@19.2.0)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.6)(react@19.2.0)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.6)(react@19.2.0)
+ '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.6)(react@19.2.0)
+ '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.6)(react@19.2.0)
+ react: 19.2.0
+ react-dom: 19.2.0(react@19.2.0)
+ optionalDependencies:
+ '@types/react': 19.2.6
+ '@types/react-dom': 19.2.3(@types/react@19.2.6)
+
+ '@radix-ui/react-tabs@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.6)(react@19.2.0)
+ '@radix-ui/react-direction': 1.1.1(@types/react@19.2.6)(react@19.2.0)
+ '@radix-ui/react-id': 1.1.1(@types/react@19.2.6)(react@19.2.0)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.6)(react@19.2.0)
+ react: 19.2.0
+ react-dom: 19.2.0(react@19.2.0)
+ optionalDependencies:
+ '@types/react': 19.2.6
+ '@types/react-dom': 19.2.3(@types/react@19.2.6)
+
+ '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.6)(react@19.2.0)':
+ dependencies:
+ react: 19.2.0
+ optionalDependencies:
+ '@types/react': 19.2.6
+
+ '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.6)(react@19.2.0)':
+ dependencies:
+ '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.6)(react@19.2.0)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.6)(react@19.2.0)
+ react: 19.2.0
+ optionalDependencies:
+ '@types/react': 19.2.6
+
+ '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.6)(react@19.2.0)':
+ dependencies:
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.6)(react@19.2.0)
+ react: 19.2.0
+ optionalDependencies:
+ '@types/react': 19.2.6
+
+ '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.6)(react@19.2.0)':
+ dependencies:
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.6)(react@19.2.0)
+ react: 19.2.0
+ optionalDependencies:
+ '@types/react': 19.2.6
+
+ '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.6)(react@19.2.0)':
+ dependencies:
+ react: 19.2.0
+ optionalDependencies:
+ '@types/react': 19.2.6
+
+ '@radix-ui/react-use-previous@1.1.1(@types/react@19.2.6)(react@19.2.0)':
+ dependencies:
+ react: 19.2.0
+ optionalDependencies:
+ '@types/react': 19.2.6
+
+ '@radix-ui/react-use-rect@1.1.1(@types/react@19.2.6)(react@19.2.0)':
+ dependencies:
+ '@radix-ui/rect': 1.1.1
+ react: 19.2.0
+ optionalDependencies:
+ '@types/react': 19.2.6
+
+ '@radix-ui/react-use-size@1.1.1(@types/react@19.2.6)(react@19.2.0)':
+ dependencies:
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.6)(react@19.2.0)
+ react: 19.2.0
+ optionalDependencies:
+ '@types/react': 19.2.6
+
+ '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
+ dependencies:
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ react: 19.2.0
+ react-dom: 19.2.0(react@19.2.0)
+ optionalDependencies:
+ '@types/react': 19.2.6
+ '@types/react-dom': 19.2.3(@types/react@19.2.6)
+
+ '@radix-ui/rect@1.1.1': {}
+
+ '@rolldown/pluginutils@1.0.0-beta.47': {}
+
+ '@rollup/rollup-android-arm-eabi@4.53.3':
+ optional: true
+
+ '@rollup/rollup-android-arm64@4.53.3':
+ optional: true
+
+ '@rollup/rollup-darwin-arm64@4.53.3':
+ optional: true
+
+ '@rollup/rollup-darwin-x64@4.53.3':
+ optional: true
+
+ '@rollup/rollup-freebsd-arm64@4.53.3':
+ optional: true
+
+ '@rollup/rollup-freebsd-x64@4.53.3':
+ optional: true
+
+ '@rollup/rollup-linux-arm-gnueabihf@4.53.3':
+ optional: true
+
+ '@rollup/rollup-linux-arm-musleabihf@4.53.3':
+ optional: true
+
+ '@rollup/rollup-linux-arm64-gnu@4.53.3':
+ optional: true
+
+ '@rollup/rollup-linux-arm64-musl@4.53.3':
+ optional: true
+
+ '@rollup/rollup-linux-loong64-gnu@4.53.3':
+ optional: true
+
+ '@rollup/rollup-linux-ppc64-gnu@4.53.3':
+ optional: true
+
+ '@rollup/rollup-linux-riscv64-gnu@4.53.3':
+ optional: true
+
+ '@rollup/rollup-linux-riscv64-musl@4.53.3':
+ optional: true
+
+ '@rollup/rollup-linux-s390x-gnu@4.53.3':
+ optional: true
+
+ '@rollup/rollup-linux-x64-gnu@4.53.3':
+ optional: true
+
+ '@rollup/rollup-linux-x64-musl@4.53.3':
+ optional: true
+
+ '@rollup/rollup-openharmony-arm64@4.53.3':
+ optional: true
+
+ '@rollup/rollup-win32-arm64-msvc@4.53.3':
+ optional: true
+
+ '@rollup/rollup-win32-ia32-msvc@4.53.3':
+ optional: true
+
+ '@rollup/rollup-win32-x64-gnu@4.53.3':
+ optional: true
+
+ '@rollup/rollup-win32-x64-msvc@4.53.3':
+ optional: true
+
+ '@tailwindcss/node@4.1.17':
+ dependencies:
+ '@jridgewell/remapping': 2.3.5
+ enhanced-resolve: 5.18.3
+ jiti: 2.6.1
+ lightningcss: 1.30.2
+ magic-string: 0.30.21
+ source-map-js: 1.2.1
+ tailwindcss: 4.1.17
+
+ '@tailwindcss/oxide-android-arm64@4.1.17':
+ optional: true
+
+ '@tailwindcss/oxide-darwin-arm64@4.1.17':
+ optional: true
+
+ '@tailwindcss/oxide-darwin-x64@4.1.17':
+ optional: true
+
+ '@tailwindcss/oxide-freebsd-x64@4.1.17':
+ optional: true
+
+ '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.17':
+ optional: true
+
+ '@tailwindcss/oxide-linux-arm64-gnu@4.1.17':
+ optional: true
+
+ '@tailwindcss/oxide-linux-arm64-musl@4.1.17':
+ optional: true
+
+ '@tailwindcss/oxide-linux-x64-gnu@4.1.17':
+ optional: true
+
+ '@tailwindcss/oxide-linux-x64-musl@4.1.17':
+ optional: true
+
+ '@tailwindcss/oxide-wasm32-wasi@4.1.17':
+ optional: true
+
+ '@tailwindcss/oxide-win32-arm64-msvc@4.1.17':
+ optional: true
+
+ '@tailwindcss/oxide-win32-x64-msvc@4.1.17':
+ optional: true
+
+ '@tailwindcss/oxide@4.1.17':
+ optionalDependencies:
+ '@tailwindcss/oxide-android-arm64': 4.1.17
+ '@tailwindcss/oxide-darwin-arm64': 4.1.17
+ '@tailwindcss/oxide-darwin-x64': 4.1.17
+ '@tailwindcss/oxide-freebsd-x64': 4.1.17
+ '@tailwindcss/oxide-linux-arm-gnueabihf': 4.1.17
+ '@tailwindcss/oxide-linux-arm64-gnu': 4.1.17
+ '@tailwindcss/oxide-linux-arm64-musl': 4.1.17
+ '@tailwindcss/oxide-linux-x64-gnu': 4.1.17
+ '@tailwindcss/oxide-linux-x64-musl': 4.1.17
+ '@tailwindcss/oxide-wasm32-wasi': 4.1.17
+ '@tailwindcss/oxide-win32-arm64-msvc': 4.1.17
+ '@tailwindcss/oxide-win32-x64-msvc': 4.1.17
+
+ '@tailwindcss/vite@4.1.17(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2))':
+ dependencies:
+ '@tailwindcss/node': 4.1.17
+ '@tailwindcss/oxide': 4.1.17
+ tailwindcss: 4.1.17
+ vite: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)
+
+ '@types/babel__core@7.20.5':
+ dependencies:
+ '@babel/parser': 7.28.5
+ '@babel/types': 7.28.5
+ '@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.28.5
+
+ '@types/babel__template@7.4.4':
+ dependencies:
+ '@babel/parser': 7.28.5
+ '@babel/types': 7.28.5
+
+ '@types/babel__traverse@7.28.0':
+ dependencies:
+ '@babel/types': 7.28.5
+
+ '@types/estree@1.0.8': {}
+
+ '@types/json-schema@7.0.15': {}
+
+ '@types/node@24.10.1':
+ dependencies:
+ undici-types: 7.16.0
+
+ '@types/react-dom@19.2.3(@types/react@19.2.6)':
+ dependencies:
+ '@types/react': 19.2.6
+
+ '@types/react@19.2.6':
+ dependencies:
+ csstype: 3.2.3
+
+ '@typescript-eslint/eslint-plugin@8.47.0(@typescript-eslint/parser@8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)':
+ dependencies:
+ '@eslint-community/regexpp': 4.12.2
+ '@typescript-eslint/parser': 8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)
+ '@typescript-eslint/scope-manager': 8.47.0
+ '@typescript-eslint/type-utils': 8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)
+ '@typescript-eslint/utils': 8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)
+ '@typescript-eslint/visitor-keys': 8.47.0
+ eslint: 9.39.1(jiti@2.6.1)
+ graphemer: 1.4.0
+ ignore: 7.0.5
+ natural-compare: 1.4.0
+ ts-api-utils: 2.1.0(typescript@5.9.3)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/parser@8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)':
+ dependencies:
+ '@typescript-eslint/scope-manager': 8.47.0
+ '@typescript-eslint/types': 8.47.0
+ '@typescript-eslint/typescript-estree': 8.47.0(typescript@5.9.3)
+ '@typescript-eslint/visitor-keys': 8.47.0
+ debug: 4.4.3
+ eslint: 9.39.1(jiti@2.6.1)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/project-service@8.47.0(typescript@5.9.3)':
+ dependencies:
+ '@typescript-eslint/tsconfig-utils': 8.47.0(typescript@5.9.3)
+ '@typescript-eslint/types': 8.47.0
+ debug: 4.4.3
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/scope-manager@8.47.0':
+ dependencies:
+ '@typescript-eslint/types': 8.47.0
+ '@typescript-eslint/visitor-keys': 8.47.0
+
+ '@typescript-eslint/tsconfig-utils@8.47.0(typescript@5.9.3)':
+ dependencies:
+ typescript: 5.9.3
+
+ '@typescript-eslint/type-utils@8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)':
+ dependencies:
+ '@typescript-eslint/types': 8.47.0
+ '@typescript-eslint/typescript-estree': 8.47.0(typescript@5.9.3)
+ '@typescript-eslint/utils': 8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)
+ debug: 4.4.3
+ eslint: 9.39.1(jiti@2.6.1)
+ ts-api-utils: 2.1.0(typescript@5.9.3)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/types@8.47.0': {}
+
+ '@typescript-eslint/typescript-estree@8.47.0(typescript@5.9.3)':
+ dependencies:
+ '@typescript-eslint/project-service': 8.47.0(typescript@5.9.3)
+ '@typescript-eslint/tsconfig-utils': 8.47.0(typescript@5.9.3)
+ '@typescript-eslint/types': 8.47.0
+ '@typescript-eslint/visitor-keys': 8.47.0
+ debug: 4.4.3
+ fast-glob: 3.3.3
+ is-glob: 4.0.3
+ minimatch: 9.0.5
+ semver: 7.7.3
+ ts-api-utils: 2.1.0(typescript@5.9.3)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/utils@8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)':
+ dependencies:
+ '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@2.6.1))
+ '@typescript-eslint/scope-manager': 8.47.0
+ '@typescript-eslint/types': 8.47.0
+ '@typescript-eslint/typescript-estree': 8.47.0(typescript@5.9.3)
+ eslint: 9.39.1(jiti@2.6.1)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/visitor-keys@8.47.0':
+ dependencies:
+ '@typescript-eslint/types': 8.47.0
+ eslint-visitor-keys: 4.2.1
+
+ '@vitejs/plugin-react@5.1.1(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2))':
+ dependencies:
+ '@babel/core': 7.28.5
+ '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.5)
+ '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.28.5)
+ '@rolldown/pluginutils': 1.0.0-beta.47
+ '@types/babel__core': 7.20.5
+ react-refresh: 0.18.0
+ vite: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)
+ transitivePeerDependencies:
+ - supports-color
+
+ acorn-jsx@5.3.2(acorn@8.15.0):
+ dependencies:
+ acorn: 8.15.0
+
+ acorn@8.15.0: {}
+
+ ajv@6.12.6:
+ 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
+
+ ansi-styles@4.3.0:
+ dependencies:
+ color-convert: 2.0.1
+
+ argparse@2.0.1: {}
+
+ aria-hidden@1.2.6:
+ dependencies:
+ tslib: 2.8.1
+
+ balanced-match@1.0.2: {}
+
+ baseline-browser-mapping@2.8.30: {}
+
+ brace-expansion@1.1.12:
+ dependencies:
+ balanced-match: 1.0.2
+ concat-map: 0.0.1
+
+ brace-expansion@2.0.2:
+ dependencies:
+ balanced-match: 1.0.2
+
+ braces@3.0.3:
+ dependencies:
+ fill-range: 7.1.1
+
+ browserslist@4.28.0:
+ dependencies:
+ baseline-browser-mapping: 2.8.30
+ caniuse-lite: 1.0.30001756
+ electron-to-chromium: 1.5.259
+ node-releases: 2.0.27
+ update-browserslist-db: 1.1.4(browserslist@4.28.0)
+
+ callsites@3.1.0: {}
+
+ caniuse-lite@1.0.30001756: {}
+
+ chalk@4.1.2:
+ dependencies:
+ ansi-styles: 4.3.0
+ supports-color: 7.2.0
+
+ class-variance-authority@0.7.1:
+ dependencies:
+ clsx: 2.1.1
+
+ clsx@2.1.1: {}
+
+ color-convert@2.0.1:
+ dependencies:
+ color-name: 1.1.4
+
+ color-name@1.1.4: {}
+
+ concat-map@0.0.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.259: {}
+
+ enhanced-resolve@5.18.3:
+ dependencies:
+ graceful-fs: 4.2.11
+ tapable: 2.3.0
+
+ esbuild@0.25.12:
+ optionalDependencies:
+ '@esbuild/aix-ppc64': 0.25.12
+ '@esbuild/android-arm': 0.25.12
+ '@esbuild/android-arm64': 0.25.12
+ '@esbuild/android-x64': 0.25.12
+ '@esbuild/darwin-arm64': 0.25.12
+ '@esbuild/darwin-x64': 0.25.12
+ '@esbuild/freebsd-arm64': 0.25.12
+ '@esbuild/freebsd-x64': 0.25.12
+ '@esbuild/linux-arm': 0.25.12
+ '@esbuild/linux-arm64': 0.25.12
+ '@esbuild/linux-ia32': 0.25.12
+ '@esbuild/linux-loong64': 0.25.12
+ '@esbuild/linux-mips64el': 0.25.12
+ '@esbuild/linux-ppc64': 0.25.12
+ '@esbuild/linux-riscv64': 0.25.12
+ '@esbuild/linux-s390x': 0.25.12
+ '@esbuild/linux-x64': 0.25.12
+ '@esbuild/netbsd-arm64': 0.25.12
+ '@esbuild/netbsd-x64': 0.25.12
+ '@esbuild/openbsd-arm64': 0.25.12
+ '@esbuild/openbsd-x64': 0.25.12
+ '@esbuild/openharmony-arm64': 0.25.12
+ '@esbuild/sunos-x64': 0.25.12
+ '@esbuild/win32-arm64': 0.25.12
+ '@esbuild/win32-ia32': 0.25.12
+ '@esbuild/win32-x64': 0.25.12
+
+ escalade@3.2.0: {}
+
+ escape-string-regexp@4.0.0: {}
+
+ eslint-plugin-react-hooks@7.0.1(eslint@9.39.1(jiti@2.6.1)):
+ dependencies:
+ '@babel/core': 7.28.5
+ '@babel/parser': 7.28.5
+ eslint: 9.39.1(jiti@2.6.1)
+ hermes-parser: 0.25.1
+ zod: 4.1.12
+ zod-validation-error: 4.0.2(zod@4.1.12)
+ transitivePeerDependencies:
+ - supports-color
+
+ eslint-plugin-react-refresh@0.4.24(eslint@9.39.1(jiti@2.6.1)):
+ dependencies:
+ eslint: 9.39.1(jiti@2.6.1)
+
+ eslint-scope@8.4.0:
+ dependencies:
+ esrecurse: 4.3.0
+ estraverse: 5.3.0
+
+ eslint-visitor-keys@3.4.3: {}
+
+ eslint-visitor-keys@4.2.1: {}
+
+ eslint@9.39.1(jiti@2.6.1):
+ dependencies:
+ '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@2.6.1))
+ '@eslint-community/regexpp': 4.12.2
+ '@eslint/config-array': 0.21.1
+ '@eslint/config-helpers': 0.4.2
+ '@eslint/core': 0.17.0
+ '@eslint/eslintrc': 3.3.1
+ '@eslint/js': 9.39.1
+ '@eslint/plugin-kit': 0.4.1
+ '@humanfs/node': 0.16.7
+ '@humanwhocodes/module-importer': 1.0.1
+ '@humanwhocodes/retry': 0.4.3
+ '@types/estree': 1.0.8
+ ajv: 6.12.6
+ chalk: 4.1.2
+ cross-spawn: 7.0.6
+ debug: 4.4.3
+ escape-string-regexp: 4.0.0
+ eslint-scope: 8.4.0
+ eslint-visitor-keys: 4.2.1
+ espree: 10.4.0
+ esquery: 1.6.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
+ lodash.merge: 4.6.2
+ minimatch: 3.1.2
+ natural-compare: 1.4.0
+ optionator: 0.9.4
+ optionalDependencies:
+ jiti: 2.6.1
+ transitivePeerDependencies:
+ - supports-color
+
+ espree@10.4.0:
+ dependencies:
+ acorn: 8.15.0
+ acorn-jsx: 5.3.2(acorn@8.15.0)
+ eslint-visitor-keys: 4.2.1
+
+ esquery@1.6.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-glob@3.3.3:
+ dependencies:
+ '@nodelib/fs.stat': 2.0.5
+ '@nodelib/fs.walk': 1.2.8
+ glob-parent: 5.1.2
+ merge2: 1.4.1
+ micromatch: 4.0.8
+
+ fast-json-stable-stringify@2.1.0: {}
+
+ fast-levenshtein@2.0.6: {}
+
+ fastq@1.19.1:
+ dependencies:
+ reusify: 1.1.0
+
+ 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
+
+ fill-range@7.1.1:
+ dependencies:
+ to-regex-range: 5.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: {}
+
+ fsevents@2.3.3:
+ optional: true
+
+ gensync@1.0.0-beta.2: {}
+
+ get-nonce@1.0.1: {}
+
+ glob-parent@5.1.2:
+ dependencies:
+ is-glob: 4.0.3
+
+ glob-parent@6.0.2:
+ dependencies:
+ is-glob: 4.0.3
+
+ globals@14.0.0: {}
+
+ globals@16.5.0: {}
+
+ graceful-fs@4.2.11: {}
+
+ graphemer@1.4.0: {}
+
+ has-flag@4.0.0: {}
+
+ hermes-estree@0.25.1: {}
+
+ hermes-parser@0.25.1:
+ dependencies:
+ hermes-estree: 0.25.1
+
+ ignore@5.3.2: {}
+
+ ignore@7.0.5: {}
+
+ import-fresh@3.3.1:
+ dependencies:
+ parent-module: 1.0.1
+ resolve-from: 4.0.0
+
+ imurmurhash@0.1.4: {}
+
+ is-extglob@2.1.1: {}
+
+ is-glob@4.0.3:
+ dependencies:
+ is-extglob: 2.1.1
+
+ is-number@7.0.0: {}
+
+ isexe@2.0.0: {}
+
+ jiti@2.6.1: {}
+
+ js-tokens@4.0.0: {}
+
+ js-yaml@4.1.1:
+ dependencies:
+ argparse: 2.0.1
+
+ 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.30.2:
+ optional: true
+
+ lightningcss-darwin-arm64@1.30.2:
+ optional: true
+
+ lightningcss-darwin-x64@1.30.2:
+ optional: true
+
+ lightningcss-freebsd-x64@1.30.2:
+ optional: true
+
+ lightningcss-linux-arm-gnueabihf@1.30.2:
+ optional: true
+
+ lightningcss-linux-arm64-gnu@1.30.2:
+ optional: true
+
+ lightningcss-linux-arm64-musl@1.30.2:
+ optional: true
+
+ lightningcss-linux-x64-gnu@1.30.2:
+ optional: true
+
+ lightningcss-linux-x64-musl@1.30.2:
+ optional: true
+
+ lightningcss-win32-arm64-msvc@1.30.2:
+ optional: true
+
+ lightningcss-win32-x64-msvc@1.30.2:
+ optional: true
+
+ lightningcss@1.30.2:
+ dependencies:
+ detect-libc: 2.1.2
+ optionalDependencies:
+ lightningcss-android-arm64: 1.30.2
+ lightningcss-darwin-arm64: 1.30.2
+ lightningcss-darwin-x64: 1.30.2
+ lightningcss-freebsd-x64: 1.30.2
+ lightningcss-linux-arm-gnueabihf: 1.30.2
+ lightningcss-linux-arm64-gnu: 1.30.2
+ lightningcss-linux-arm64-musl: 1.30.2
+ lightningcss-linux-x64-gnu: 1.30.2
+ lightningcss-linux-x64-musl: 1.30.2
+ lightningcss-win32-arm64-msvc: 1.30.2
+ lightningcss-win32-x64-msvc: 1.30.2
+
+ locate-path@6.0.0:
+ dependencies:
+ p-locate: 5.0.0
+
+ lodash.merge@4.6.2: {}
+
+ lru-cache@5.1.1:
+ dependencies:
+ yallist: 3.1.1
+
+ lucide-react@0.554.0(react@19.2.0):
+ dependencies:
+ react: 19.2.0
+
+ magic-string@0.30.21:
+ dependencies:
+ '@jridgewell/sourcemap-codec': 1.5.5
+
+ merge2@1.4.1: {}
+
+ micromatch@4.0.8:
+ dependencies:
+ braces: 3.0.3
+ picomatch: 2.3.1
+
+ minimatch@3.1.2:
+ dependencies:
+ brace-expansion: 1.1.12
+
+ minimatch@9.0.5:
+ dependencies:
+ brace-expansion: 2.0.2
+
+ ms@2.1.3: {}
+
+ nanoid@3.3.11: {}
+
+ natural-compare@1.4.0: {}
+
+ next-themes@0.4.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
+ dependencies:
+ react: 19.2.0
+ react-dom: 19.2.0(react@19.2.0)
+
+ 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
+
+ parent-module@1.0.1:
+ dependencies:
+ callsites: 3.1.0
+
+ path-exists@4.0.0: {}
+
+ path-key@3.1.1: {}
+
+ picocolors@1.1.1: {}
+
+ picomatch@2.3.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: {}
+
+ queue-microtask@1.2.3: {}
+
+ react-dom@19.2.0(react@19.2.0):
+ dependencies:
+ react: 19.2.0
+ scheduler: 0.27.0
+
+ react-refresh@0.18.0: {}
+
+ react-remove-scroll-bar@2.3.8(@types/react@19.2.6)(react@19.2.0):
+ dependencies:
+ react: 19.2.0
+ react-style-singleton: 2.2.3(@types/react@19.2.6)(react@19.2.0)
+ tslib: 2.8.1
+ optionalDependencies:
+ '@types/react': 19.2.6
+
+ react-remove-scroll@2.7.1(@types/react@19.2.6)(react@19.2.0):
+ dependencies:
+ react: 19.2.0
+ react-remove-scroll-bar: 2.3.8(@types/react@19.2.6)(react@19.2.0)
+ react-style-singleton: 2.2.3(@types/react@19.2.6)(react@19.2.0)
+ tslib: 2.8.1
+ use-callback-ref: 1.3.3(@types/react@19.2.6)(react@19.2.0)
+ use-sidecar: 1.1.3(@types/react@19.2.6)(react@19.2.0)
+ optionalDependencies:
+ '@types/react': 19.2.6
+
+ react-style-singleton@2.2.3(@types/react@19.2.6)(react@19.2.0):
+ dependencies:
+ get-nonce: 1.0.1
+ react: 19.2.0
+ tslib: 2.8.1
+ optionalDependencies:
+ '@types/react': 19.2.6
+
+ react@19.2.0: {}
+
+ resolve-from@4.0.0: {}
+
+ reusify@1.1.0: {}
+
+ rollup@4.53.3:
+ dependencies:
+ '@types/estree': 1.0.8
+ optionalDependencies:
+ '@rollup/rollup-android-arm-eabi': 4.53.3
+ '@rollup/rollup-android-arm64': 4.53.3
+ '@rollup/rollup-darwin-arm64': 4.53.3
+ '@rollup/rollup-darwin-x64': 4.53.3
+ '@rollup/rollup-freebsd-arm64': 4.53.3
+ '@rollup/rollup-freebsd-x64': 4.53.3
+ '@rollup/rollup-linux-arm-gnueabihf': 4.53.3
+ '@rollup/rollup-linux-arm-musleabihf': 4.53.3
+ '@rollup/rollup-linux-arm64-gnu': 4.53.3
+ '@rollup/rollup-linux-arm64-musl': 4.53.3
+ '@rollup/rollup-linux-loong64-gnu': 4.53.3
+ '@rollup/rollup-linux-ppc64-gnu': 4.53.3
+ '@rollup/rollup-linux-riscv64-gnu': 4.53.3
+ '@rollup/rollup-linux-riscv64-musl': 4.53.3
+ '@rollup/rollup-linux-s390x-gnu': 4.53.3
+ '@rollup/rollup-linux-x64-gnu': 4.53.3
+ '@rollup/rollup-linux-x64-musl': 4.53.3
+ '@rollup/rollup-openharmony-arm64': 4.53.3
+ '@rollup/rollup-win32-arm64-msvc': 4.53.3
+ '@rollup/rollup-win32-ia32-msvc': 4.53.3
+ '@rollup/rollup-win32-x64-gnu': 4.53.3
+ '@rollup/rollup-win32-x64-msvc': 4.53.3
+ fsevents: 2.3.3
+
+ run-parallel@1.2.0:
+ dependencies:
+ queue-microtask: 1.2.3
+
+ scheduler@0.27.0: {}
+
+ semver@6.3.1: {}
+
+ semver@7.7.3: {}
+
+ sharp@0.34.5:
+ dependencies:
+ '@img/colour': 1.0.0
+ detect-libc: 2.1.2
+ semver: 7.7.3
+ 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.0(react@19.2.0))(react@19.2.0):
+ dependencies:
+ react: 19.2.0
+ react-dom: 19.2.0(react@19.2.0)
+
+ source-map-js@1.2.1: {}
+
+ strip-json-comments@3.1.1: {}
+
+ supports-color@7.2.0:
+ dependencies:
+ has-flag: 4.0.0
+
+ tailwind-merge@3.4.0: {}
+
+ tailwindcss@4.1.17: {}
+
+ tapable@2.3.0: {}
+
+ tinyglobby@0.2.15:
+ dependencies:
+ fdir: 6.5.0(picomatch@4.0.3)
+ picomatch: 4.0.3
+
+ to-regex-range@5.0.1:
+ dependencies:
+ is-number: 7.0.0
+
+ ts-api-utils@2.1.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.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3):
+ dependencies:
+ '@typescript-eslint/eslint-plugin': 8.47.0(@typescript-eslint/parser@8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)
+ '@typescript-eslint/parser': 8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)
+ '@typescript-eslint/typescript-estree': 8.47.0(typescript@5.9.3)
+ '@typescript-eslint/utils': 8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)
+ eslint: 9.39.1(jiti@2.6.1)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ typescript@5.9.3: {}
+
+ undici-types@7.16.0: {}
+
+ update-browserslist-db@1.1.4(browserslist@4.28.0):
+ dependencies:
+ browserslist: 4.28.0
+ 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.6)(react@19.2.0):
+ dependencies:
+ react: 19.2.0
+ tslib: 2.8.1
+ optionalDependencies:
+ '@types/react': 19.2.6
+
+ use-sidecar@1.1.3(@types/react@19.2.6)(react@19.2.0):
+ dependencies:
+ detect-node-es: 1.1.0
+ react: 19.2.0
+ tslib: 2.8.1
+ optionalDependencies:
+ '@types/react': 19.2.6
+
+ vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2):
+ dependencies:
+ esbuild: 0.25.12
+ fdir: 6.5.0(picomatch@4.0.3)
+ picomatch: 4.0.3
+ postcss: 8.5.6
+ rollup: 4.53.3
+ tinyglobby: 0.2.15
+ optionalDependencies:
+ '@types/node': 24.10.1
+ fsevents: 2.3.3
+ jiti: 2.6.1
+ lightningcss: 1.30.2
+
+ 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.1.12):
+ dependencies:
+ zod: 4.1.12
+
+ zod@4.1.12: {}
diff --git a/frontend/public/icon.svg b/frontend/public/icon.svg
new file mode 100644
index 0000000..da7bd7f
--- /dev/null
+++ b/frontend/public/icon.svg
@@ -0,0 +1,39 @@
+
+
\ No newline at end of file
diff --git a/frontend/scripts/generate-icon.js b/frontend/scripts/generate-icon.js
new file mode 100644
index 0000000..b975dd8
--- /dev/null
+++ b/frontend/scripts/generate-icon.js
@@ -0,0 +1,33 @@
+import sharp from 'sharp';
+import { readFileSync, mkdirSync } from 'fs';
+import { dirname, join } from 'path';
+import { fileURLToPath } from 'url';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const rootDir = join(__dirname, '..', '..');
+
+const svgPath = join(rootDir, 'frontend', 'public', 'icon.svg');
+const outputPath = join(rootDir, 'build', 'appicon.png');
+
+async function generateIcon() {
+ try {
+ // Ensure build directory exists
+ mkdirSync(join(rootDir, 'build'), { recursive: true });
+
+ // Read SVG
+ const svgBuffer = readFileSync(svgPath);
+
+ // Convert SVG to PNG (1024x1024 for Wails)
+ await sharp(svgBuffer)
+ .resize(1024, 1024)
+ .png()
+ .toFile(outputPath);
+
+ console.log('✓ Icon generated:', outputPath);
+ } catch (error) {
+ console.error('✗ Failed to generate icon:', error.message);
+ process.exit(1);
+ }
+}
+
+generateIcon();
diff --git a/frontend/src/App.css b/frontend/src/App.css
new file mode 100644
index 0000000..b9d355d
--- /dev/null
+++ b/frontend/src/App.css
@@ -0,0 +1,42 @@
+#root {
+ max-width: 1280px;
+ margin: 0 auto;
+ padding: 2rem;
+ text-align: center;
+}
+
+.logo {
+ height: 6em;
+ padding: 1.5em;
+ will-change: filter;
+ transition: filter 300ms;
+}
+.logo:hover {
+ filter: drop-shadow(0 0 2em #646cffaa);
+}
+.logo.react:hover {
+ filter: drop-shadow(0 0 2em #61dafbaa);
+}
+
+@keyframes logo-spin {
+ from {
+ transform: rotate(0deg);
+ }
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+@media (prefers-reduced-motion: no-preference) {
+ a:nth-of-type(2) .logo {
+ animation: logo-spin infinite 20s linear;
+ }
+}
+
+.card {
+ padding: 2em;
+}
+
+.read-the-docs {
+ color: #888;
+}
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
new file mode 100644
index 0000000..c3590a1
--- /dev/null
+++ b/frontend/src/App.tsx
@@ -0,0 +1,1022 @@
+import { useState, useEffect, useRef } from "react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { Label } from "@/components/ui/label";
+import { Checkbox } from "@/components/ui/checkbox";
+import { Progress } from "@/components/ui/progress";
+import { Badge } from "@/components/ui/badge";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { fetchSpotifyMetadata, downloadTrack } from "@/lib/api";
+import type { SpotifyMetadataResponse, TrackMetadata } from "@/types/api";
+import { Settings } from "@/components/Settings";
+import { getSettings } from "@/lib/settings";
+import { applyTheme } from "@/lib/themes";
+import { Download, Search, Loader2, CheckCircle } from "lucide-react";
+import { toast } from "sonner";
+import {
+ Pagination,
+ PaginationContent,
+ PaginationItem,
+ PaginationLink,
+ PaginationNext,
+ PaginationPrevious,
+} from "@/components/ui/pagination";
+
+function App() {
+ const [spotifyUrl, setSpotifyUrl] = useState("");
+ const [loading, setLoading] = useState(false);
+ const [metadata, setMetadata] = useState(null);
+ const [selectedTracks, setSelectedTracks] = useState([]);
+ const [searchQuery, setSearchQuery] = useState("");
+ const [downloadProgress, setDownloadProgress] = useState(0);
+ const [isDownloading, setIsDownloading] = useState(false);
+ const [downloadingTrack, setDownloadingTrack] = useState(null);
+ const [bulkDownloadType, setBulkDownloadType] = useState<'all' | 'selected' | null>(null);
+ const [downloadedTracks, setDownloadedTracks] = useState>(new Set());
+ const [showTimeoutDialog, setShowTimeoutDialog] = useState(false);
+ const [timeoutValue, setTimeoutValue] = useState(60);
+ const [pendingUrl, setPendingUrl] = useState("");
+ const [currentPage, setCurrentPage] = useState(1);
+ const [hasUpdate, setHasUpdate] = useState(false);
+ const shouldStopDownloadRef = useRef(false);
+
+ const ITEMS_PER_PAGE = 50;
+ const CURRENT_VERSION = "5.5";
+
+ useEffect(() => {
+ const settings = getSettings();
+ if (settings.darkMode) {
+ document.documentElement.classList.add("dark");
+ } else {
+ document.documentElement.classList.remove("dark");
+ }
+ applyTheme(settings.theme);
+
+ // Check for updates
+ checkForUpdates();
+ }, []);
+
+ const checkForUpdates = async () => {
+ try {
+ const response = await fetch('https://raw.githubusercontent.com/afkarxyz/SpotiFLAC/refs/heads/main/version.json');
+ const data = await response.json();
+ const latestVersion = data.version;
+
+ // Compare versions (simple string comparison works for x.y format)
+ if (latestVersion > CURRENT_VERSION) {
+ setHasUpdate(true);
+ }
+ } catch (err) {
+ // Silently fail if update check fails
+ console.error('Failed to check for updates:', err);
+ }
+ };
+
+ useEffect(() => {
+ // Clear selection, search, downloaded tracks, and reset page when metadata changes
+ setSelectedTracks([]);
+ setSearchQuery("");
+ setDownloadedTracks(new Set());
+ setCurrentPage(1);
+ }, [metadata]);
+
+ const downloadWithAutoFallback = async (isrc: string, settings: any, trackName?: string, artistName?: string, folderName?: string) => {
+ let service = settings.downloader;
+
+ // Build query for Tidal (title + artist)
+ const query = trackName && artistName ? `${trackName} ${artistName}` : undefined;
+
+ // Sanitize folder name (remove illegal characters for Windows)
+ const sanitizedFolderName = folderName
+ ? folderName.replace(/[<>:"/\\|?*]/g, '_').trim()
+ : undefined;
+
+ // Build output directory with folder name if provided
+ const outputDir = sanitizedFolderName
+ ? `${settings.downloadPath}\\${sanitizedFolderName}`
+ : settings.downloadPath;
+
+ // If auto mode, try Tidal first
+ if (service === "auto") {
+ try {
+ const tidalResponse = await downloadTrack({
+ isrc,
+ service: "tidal",
+ query,
+ output_dir: outputDir,
+ });
+
+ if (tidalResponse.success) {
+ return tidalResponse;
+ }
+
+ // Tidal failed, try Deezer
+ service = "deezer";
+ } catch (tidalErr) {
+ service = "deezer";
+ }
+ }
+
+ // Use selected service or fallback to Deezer
+ return await downloadTrack({
+ isrc,
+ service: service as "deezer" | "tidal",
+ query,
+ output_dir: outputDir,
+ });
+ };
+
+ const handleFetchMetadata = async () => {
+ if (!spotifyUrl.trim()) {
+ toast.error("Please enter a Spotify URL");
+ return;
+ }
+
+ let urlToFetch = spotifyUrl.trim();
+ const isArtistUrl = urlToFetch.includes('/artist/');
+
+ // Auto-convert artist URL to discography
+ if (isArtistUrl && !urlToFetch.includes('/discography')) {
+ urlToFetch = urlToFetch.replace(/\/$/, '') + '/discography/all';
+ setSpotifyUrl(urlToFetch);
+ }
+
+ // Show timeout dialog only for artist URLs
+ if (isArtistUrl) {
+ setPendingUrl(urlToFetch);
+ setShowTimeoutDialog(true);
+ } else {
+ // Directly fetch for non-artist URLs (track, album, playlist)
+ await fetchMetadataDirectly(urlToFetch);
+ }
+ };
+
+ const fetchMetadataDirectly = async (url: string) => {
+ setLoading(true);
+ setMetadata(null);
+
+ try {
+ const data = await fetchSpotifyMetadata(url);
+ setMetadata(data);
+ toast.success("Metadata fetched successfully");
+ } catch (err) {
+ toast.error(err instanceof Error ? err.message : "Failed to fetch metadata");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleConfirmFetch = async () => {
+ setShowTimeoutDialog(false);
+ setLoading(true);
+ setMetadata(null);
+
+ try {
+ const data = await fetchSpotifyMetadata(pendingUrl, true, 1.0, timeoutValue);
+ setMetadata(data);
+ toast.success("Metadata fetched successfully");
+ } catch (err) {
+ toast.error(err instanceof Error ? err.message : "Failed to fetch metadata");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleDownloadTrack = async (isrc: string, trackName?: string, artistName?: string) => {
+ if (!isrc) {
+ toast.error("No ISRC found for this track");
+ return;
+ }
+
+ const settings = getSettings();
+ setDownloadingTrack(isrc);
+
+ try {
+ const response = await downloadWithAutoFallback(isrc, settings, trackName, artistName);
+
+ if (response.success) {
+ toast.success(response.message);
+ setDownloadedTracks(prev => new Set(prev).add(isrc));
+ } else {
+ toast.error(response.error);
+ }
+ } catch (err) {
+ toast.error(err instanceof Error ? err.message : "Download failed");
+ } finally {
+ setDownloadingTrack(null);
+ }
+ };
+
+ const handleDownloadSelected = async () => {
+ if (selectedTracks.length === 0) {
+ toast.error("No tracks selected");
+ return;
+ }
+
+ const settings = getSettings();
+ setIsDownloading(true);
+ setBulkDownloadType('selected');
+ setDownloadProgress(0);
+
+ let successCount = 0;
+ let errorCount = 0;
+ const total = selectedTracks.length;
+
+ // Get all tracks and folder name from metadata
+ let allTracks: TrackMetadata[] = [];
+ let folderName: string | undefined;
+
+ if (metadata && "track_list" in metadata) {
+ allTracks = metadata.track_list;
+
+ // Get folder name from album or playlist
+ if ("album_info" in metadata) {
+ folderName = metadata.album_info.name;
+ } else if ("playlist_info" in metadata) {
+ folderName = metadata.playlist_info.owner.name;
+ }
+ }
+
+ for (let i = 0; i < selectedTracks.length; i++) {
+ // Check if user clicked Stop
+ if (shouldStopDownloadRef.current) {
+ toast.info(`Download stopped. ${successCount} tracks downloaded, ${selectedTracks.length - i} skipped.`);
+ break;
+ }
+
+ const isrc = selectedTracks[i];
+ const track = allTracks.find(t => t.isrc === isrc);
+
+ setDownloadingTrack(isrc); // Show spinner on this track
+
+ try {
+ const response = await downloadWithAutoFallback(
+ isrc,
+ settings,
+ track?.name,
+ track?.artists,
+ folderName
+ );
+
+ if (response.success) {
+ successCount++;
+ setDownloadedTracks(prev => new Set(prev).add(isrc));
+ } else {
+ errorCount++;
+ }
+ } catch (err) {
+ errorCount++;
+ }
+
+ setDownloadProgress(Math.round(((i + 1) / total) * 100));
+ }
+
+ setDownloadingTrack(null); // Clear spinner
+ setIsDownloading(false);
+ setBulkDownloadType(null);
+ shouldStopDownloadRef.current = false; // Reset flag
+
+ if (errorCount === 0) {
+ toast.success(`Downloaded ${successCount} tracks successfully`);
+ } else {
+ toast.warning(`Downloaded ${successCount} tracks, ${errorCount} failed`);
+ }
+
+ setSelectedTracks([]);
+ };
+
+ const handleDownloadAll = async (tracks: TrackMetadata[], folderName?: string) => {
+ const tracksWithIsrc = tracks.filter(track => track.isrc);
+
+ if (tracksWithIsrc.length === 0) {
+ toast.error("No tracks available for download");
+ return;
+ }
+
+ const settings = getSettings();
+ setIsDownloading(true);
+ setBulkDownloadType('all');
+ setDownloadProgress(0);
+
+ let successCount = 0;
+ let errorCount = 0;
+ const total = tracksWithIsrc.length;
+
+ for (let i = 0; i < tracksWithIsrc.length; i++) {
+ // Check if user clicked Stop
+ if (shouldStopDownloadRef.current) {
+ toast.info(`Download stopped. ${successCount} tracks downloaded, ${tracksWithIsrc.length - i} skipped.`);
+ break;
+ }
+
+ const track = tracksWithIsrc[i];
+
+ setDownloadingTrack(track.isrc); // Show spinner on this track
+
+ try {
+ const response = await downloadWithAutoFallback(
+ track.isrc,
+ settings,
+ track.name,
+ track.artists,
+ folderName
+ );
+
+ if (response.success) {
+ successCount++;
+ setDownloadedTracks(prev => new Set(prev).add(track.isrc));
+ } else {
+ errorCount++;
+ }
+ } catch (err) {
+ errorCount++;
+ }
+
+ setDownloadProgress(Math.round(((i + 1) / total) * 100));
+ }
+
+ setDownloadingTrack(null); // Clear spinner
+ setIsDownloading(false);
+ setBulkDownloadType(null);
+ shouldStopDownloadRef.current = false; // Reset flag
+
+ if (errorCount === 0) {
+ toast.success(`Downloaded ${successCount} tracks successfully`);
+ } else {
+ toast.warning(`Downloaded ${successCount} tracks, ${errorCount} failed`);
+ }
+ };
+
+ const toggleTrackSelection = (isrc: string) => {
+ setSelectedTracks(prev =>
+ prev.includes(isrc)
+ ? prev.filter(id => id !== isrc)
+ : [...prev, isrc]
+ );
+ };
+
+ const toggleSelectAll = (tracks: TrackMetadata[]) => {
+ const tracksWithIsrc = tracks.filter(track => track.isrc).map(track => track.isrc);
+ if (selectedTracks.length === tracksWithIsrc.length) {
+ setSelectedTracks([]);
+ } else {
+ setSelectedTracks(tracksWithIsrc);
+ }
+ };
+
+ 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 handleSearchChange = (value: string) => {
+ setSearchQuery(value);
+ setCurrentPage(1); // Reset to first page when searching
+ };
+
+ const handleStopDownload = () => {
+ shouldStopDownloadRef.current = true;
+ toast.info('Stopping download...');
+ };
+
+ const renderDownloadProgress = () => {
+ if (!isDownloading) return null;
+
+ return (
+
+
+
+ {downloadProgress}% complete ({bulkDownloadType === 'all' ? 'Downloading all tracks' : 'Downloading selected tracks'})
+
+
+ );
+ };
+
+ const renderTrackList = (tracks: TrackMetadata[], showCheckboxes: boolean = false, hideAlbumColumn: boolean = false) => {
+ const 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)
+ );
+ });
+
+ // Pagination
+ const totalPages = Math.ceil(filteredTracks.length / ITEMS_PER_PAGE);
+ const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
+ const endIndex = startIndex + ITEMS_PER_PAGE;
+ const paginatedTracks = filteredTracks.slice(startIndex, endIndex);
+
+ const tracksWithIsrc = filteredTracks.filter(track => track.isrc);
+ const allSelected = tracksWithIsrc.length > 0 && tracksWithIsrc.every(track => selectedTracks.includes(track.isrc));
+
+ return (
+
+
+
+
+
+
+ {showCheckboxes && (
+ |
+ toggleSelectAll(filteredTracks)}
+ />
+ |
+ )}
+ # |
+ Title |
+ {!hideAlbumColumn && Album | }
+ Duration |
+ Actions |
+
+
+
+ {paginatedTracks.map((track, index) => (
+
+ {showCheckboxes && (
+ |
+ {track.isrc && (
+ toggleTrackSelection(track.isrc)}
+ />
+ )}
+ |
+ )}
+
+ {startIndex + index + 1}
+ |
+
+
+ {track.images && (
+ 
+ )}
+
+
+ {track.name}
+ {downloadedTracks.has(track.isrc) && (
+
+ )}
+
+ {track.artists}
+
+
+ |
+ {!hideAlbumColumn && (
+
+ {track.album_name}
+ |
+ )}
+
+ {formatDuration(track.duration_ms)}
+ |
+
+ {track.isrc && (
+
+ )}
+ |
+
+ ))}
+
+
+
+
+
+ {/* Pagination */}
+ {totalPages > 1 && (
+
+
+
+ {
+ e.preventDefault();
+ if (currentPage > 1) setCurrentPage(currentPage - 1);
+ }}
+ className={currentPage === 1 ? 'pointer-events-none opacity-50' : 'cursor-pointer'}
+ />
+
+
+ {Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
+
+ {
+ e.preventDefault();
+ setCurrentPage(page);
+ }}
+ isActive={currentPage === page}
+ className="cursor-pointer"
+ >
+ {page}
+
+
+ ))}
+
+
+ {
+ e.preventDefault();
+ if (currentPage < totalPages) setCurrentPage(currentPage + 1);
+ }}
+ className={currentPage === totalPages ? 'pointer-events-none opacity-50' : 'cursor-pointer'}
+ />
+
+
+
+ )}
+
+ );
+ };
+
+ const renderMetadata = () => {
+ if (!metadata) return null;
+
+ if ("track" in metadata) {
+ const { track } = metadata;
+ return (
+
+
+
+ {track.images && (
+

+ )}
+
+
+
{track.name}
+
{track.artists}
+
+
+
+
Album
+
{track.album_name}
+
+
+
Release Date
+
{track.release_date}
+
+
+ {track.isrc && (
+
+
+
+ )}
+
+
+
+
+ );
+ }
+
+ if ("album_info" in metadata) {
+ const { album_info, track_list } = metadata;
+ return (
+
+
+
+
+ {album_info.images && (
+

+ )}
+
+
+
Album
+
{album_info.name}
+
+ {album_info.artists}
+ •
+ {album_info.release_date}
+ •
+ {album_info.total_tracks} songs
+
+
+
+
+ {selectedTracks.length > 0 && (
+
+ )}
+
+ {renderDownloadProgress()}
+
+
+
+
+
+
+
+ handleSearchChange(e.target.value)}
+ className="pl-10"
+ />
+
+ {renderTrackList(track_list, true, true)}
+
+
+ );
+ }
+
+ if ("playlist_info" in metadata) {
+ const { playlist_info, track_list } = metadata;
+ return (
+
+
+
+
+ {playlist_info.owner.images && (
+

+ )}
+
+
+
Playlist
+
{playlist_info.owner.name}
+
+ {playlist_info.owner.display_name}
+ •
+ {playlist_info.tracks.total} songs
+ •
+ {playlist_info.followers.total.toLocaleString()} followers
+
+
+
+
+ {selectedTracks.length > 0 && (
+
+ )}
+
+ {renderDownloadProgress()}
+
+
+
+
+
+
+
+ handleSearchChange(e.target.value)}
+ className="pl-10"
+ />
+
+ {renderTrackList(track_list, true)}
+
+
+ );
+ }
+
+ if ("artist_info" in metadata) {
+ const { artist_info, album_list, track_list } = metadata;
+ return (
+
+
+
+
+ {artist_info.images && (
+

+ )}
+
+
Artist
+
{artist_info.name}
+
+ {artist_info.followers.toLocaleString()} followers
+ {artist_info.genres.length > 0 && (
+ <>
+ •
+ {artist_info.genres.join(", ")}
+ >
+ )}
+
+
+
+
+
+
+ {album_list.length > 0 && (
+
+
Discography
+
+ {album_list.map((album) => (
+
+
+ {album.images && (
+

+ )}
+
+
{album.name}
+
+ {album.release_date?.split('-')[0]} • {album.album_type}
+
+
+ ))}
+
+
+ )}
+
+ {track_list.length > 0 && (
+
+
+
Popular Tracks
+
+
+ {selectedTracks.length > 0 && (
+
+ )}
+
+
+ {renderDownloadProgress()}
+
+
+ handleSearchChange(e.target.value)}
+ className="pl-10"
+ />
+
+ {renderTrackList(track_list, true)}
+
+ )}
+
+ );
+ }
+
+ if ("artist" in metadata) {
+ const { artist } = metadata;
+ return (
+
+
+ Artist: {artist.name}
+
+ {artist.followers.toLocaleString()} followers • Popularity: {artist.popularity}
+
+
+
+ {artist.images && (
+
+ )}
+ {artist.genres.length > 0 && (
+
+
+
{artist.genres.join(", ")}
+
+ )}
+
+
+ );
+ }
+
+ return null;
+ };
+
+ return (
+
+
+
+
+
+

+
SpotiFLAC
+
+
+
+ Get Spotify tracks in true FLAC from Tidal/Deezer — no account required.
+
+
+
+
+
+ {/* Timeout Dialog */}
+
+
+
+
+
+
+
+ setSpotifyUrl(e.target.value)}
+ onKeyDown={(e) => e.key === "Enter" && handleFetchMetadata()}
+ />
+
+
+
+ Supports track, album, playlist, and artist URLs
+
+
+
+
+
+ {metadata && renderMetadata()}
+
+
+ );
+}
+
+export default App;
diff --git a/frontend/src/components/Settings.tsx b/frontend/src/components/Settings.tsx
new file mode 100644
index 0000000..73f4fcc
--- /dev/null
+++ b/frontend/src/components/Settings.tsx
@@ -0,0 +1,280 @@
+import { useState, useEffect } from "react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import {
+ Dialog,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Switch } from "@/components/ui/switch";
+import { Checkbox } from "@/components/ui/checkbox";
+import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
+import { Settings as SettingsIcon, FolderOpen } from "lucide-react";
+import { getSettings, getSettingsWithDefaults, saveSettings, type Settings as SettingsType } from "@/lib/settings";
+import { themes, applyTheme } from "@/lib/themes";
+import { OpenFolder } from "../../wailsjs/go/main/App";
+
+export function Settings() {
+ const [open, setOpen] = useState(false);
+ const [savedSettings, setSavedSettings] = useState(getSettings());
+ const [tempSettings, setTempSettings] = useState(savedSettings);
+ const [, setIsLoadingDefaults] = useState(false);
+
+ // Apply saved settings
+ useEffect(() => {
+ if (savedSettings.darkMode) {
+ document.documentElement.classList.add("dark");
+ } else {
+ document.documentElement.classList.remove("dark");
+ }
+ applyTheme(savedSettings.theme);
+ }, [savedSettings.darkMode, savedSettings.theme]);
+
+ // Apply temp settings for preview when dialog is open
+ useEffect(() => {
+ if (open) {
+ if (tempSettings.darkMode) {
+ document.documentElement.classList.add("dark");
+ } else {
+ document.documentElement.classList.remove("dark");
+ }
+ applyTheme(tempSettings.theme);
+ }
+ }, [open, tempSettings.darkMode, tempSettings.theme]);
+
+ useEffect(() => {
+ // Load settings with defaults from backend on mount
+ const loadDefaults = async () => {
+ if (!savedSettings.downloadPath) {
+ setIsLoadingDefaults(true);
+ const settingsWithDefaults = await getSettingsWithDefaults();
+ setSavedSettings(settingsWithDefaults);
+ setTempSettings(settingsWithDefaults);
+ setIsLoadingDefaults(false);
+ }
+ };
+ loadDefaults();
+ }, []);
+
+ // Reset temp settings when dialog opens
+ useEffect(() => {
+ if (open) {
+ setTempSettings(savedSettings);
+ }
+ }, [open, savedSettings]);
+
+ const handleSave = () => {
+ saveSettings(tempSettings);
+ setSavedSettings(tempSettings);
+ setOpen(false);
+ };
+
+ const handleCancel = () => {
+ // Revert to saved settings
+ if (savedSettings.darkMode) {
+ document.documentElement.classList.add("dark");
+ } else {
+ document.documentElement.classList.remove("dark");
+ }
+ applyTheme(savedSettings.theme);
+
+ setTempSettings(savedSettings);
+ setOpen(false);
+ };
+
+ const handleOpenChange = (newOpen: boolean) => {
+ if (!newOpen) {
+ // Dialog is closing, revert to saved settings
+ if (savedSettings.darkMode) {
+ document.documentElement.classList.add("dark");
+ } else {
+ document.documentElement.classList.remove("dark");
+ }
+ applyTheme(savedSettings.theme);
+ setTempSettings(savedSettings);
+ }
+ setOpen(newOpen);
+ };
+
+ const handleDownloadPathChange = (value: string) => {
+ setTempSettings((prev) => ({ ...prev, downloadPath: value }));
+ };
+
+ const handleDownloaderChange = (value: "auto" | "deezer" | "tidal") => {
+ setTempSettings((prev) => ({ ...prev, downloader: value }));
+ };
+
+ const handleThemeChange = (value: string) => {
+ setTempSettings((prev) => ({ ...prev, theme: value }));
+ };
+
+ const toggleDarkMode = () => {
+ setTempSettings((prev) => ({ ...prev, darkMode: !prev.darkMode }));
+ };
+
+ const handleBrowseFolder = async () => {
+ if (!tempSettings.downloadPath) {
+ alert("Please enter a download path first");
+ return;
+ }
+
+ try {
+ // Call backend to open folder in file explorer
+ await OpenFolder(tempSettings.downloadPath);
+ } catch (error) {
+ console.error("Error opening folder:", error);
+ alert(`Error opening folder: ${error}`);
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/frontend/src/components/ui/alert.tsx b/frontend/src/components/ui/alert.tsx
new file mode 100644
index 0000000..1421354
--- /dev/null
+++ b/frontend/src/components/ui/alert.tsx
@@ -0,0 +1,66 @@
+import * as React from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const alertVariants = cva(
+ "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
+ {
+ variants: {
+ variant: {
+ default: "bg-card text-card-foreground",
+ destructive:
+ "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+function Alert({
+ className,
+ variant,
+ ...props
+}: React.ComponentProps<"div"> & VariantProps) {
+ return (
+
+ )
+}
+
+function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function AlertDescription({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+export { Alert, AlertTitle, AlertDescription }
diff --git a/frontend/src/components/ui/badge.tsx b/frontend/src/components/ui/badge.tsx
new file mode 100644
index 0000000..7dfdb90
--- /dev/null
+++ b/frontend/src/components/ui/badge.tsx
@@ -0,0 +1,40 @@
+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 rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
+ {
+ variants: {
+ variant: {
+ default:
+ "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
+ secondary:
+ "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
+ destructive:
+ "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
+ outline: "text-foreground",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+export interface BadgeProps
+ extends React.HTMLAttributes,
+ VariantProps {
+ asChild?: boolean
+}
+
+function Badge({ className, variant, asChild = false, ...props }: BadgeProps) {
+ const Comp = asChild ? Slot : "div"
+ return (
+
+ )
+}
+
+export { Badge, badgeVariants }
diff --git a/frontend/src/components/ui/button.tsx b/frontend/src/components/ui/button.tsx
new file mode 100644
index 0000000..21409a0
--- /dev/null
+++ b/frontend/src/components/ui/button.tsx
@@ -0,0 +1,60 @@
+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 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: "size-9",
+ "icon-sm": "size-8",
+ "icon-lg": "size-10",
+ },
+ },
+ 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
new file mode 100644
index 0000000..681ad98
--- /dev/null
+++ b/frontend/src/components/ui/card.tsx
@@ -0,0 +1,92 @@
+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
new file mode 100644
index 0000000..0e2a6cd
--- /dev/null
+++ b/frontend/src/components/ui/checkbox.tsx
@@ -0,0 +1,30 @@
+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/dialog.tsx b/frontend/src/components/ui/dialog.tsx
new file mode 100644
index 0000000..6cb123b
--- /dev/null
+++ b/frontend/src/components/ui/dialog.tsx
@@ -0,0 +1,141 @@
+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/input.tsx b/frontend/src/components/ui/input.tsx
new file mode 100644
index 0000000..8916905
--- /dev/null
+++ b/frontend/src/components/ui/input.tsx
@@ -0,0 +1,21 @@
+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
new file mode 100644
index 0000000..ef7133a
--- /dev/null
+++ b/frontend/src/components/ui/label.tsx
@@ -0,0 +1,22 @@
+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/pagination.tsx b/frontend/src/components/ui/pagination.tsx
new file mode 100644
index 0000000..9071769
--- /dev/null
+++ b/frontend/src/components/ui/pagination.tsx
@@ -0,0 +1,127 @@
+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 (
+
+ )
+}
+
+function PaginationContent({
+ className,
+ ...props
+}: React.ComponentProps<"ul">) {
+ return (
+
+ )
+}
+
+function PaginationItem({ ...props }: React.ComponentProps<"li">) {
+ return
+}
+
+type PaginationLinkProps = {
+ isActive?: boolean
+} & Pick, "size"> &
+ React.ComponentProps<"a">
+
+function PaginationLink({
+ className,
+ isActive,
+ size = "icon",
+ ...props
+}: PaginationLinkProps) {
+ return (
+
+ )
+}
+
+function PaginationPrevious({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+ Previous
+
+ )
+}
+
+function PaginationNext({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ Next
+
+
+ )
+}
+
+function PaginationEllipsis({
+ className,
+ ...props
+}: React.ComponentProps<"span">) {
+ return (
+
+
+ More pages
+
+ )
+}
+
+export {
+ Pagination,
+ PaginationContent,
+ PaginationLink,
+ PaginationItem,
+ PaginationPrevious,
+ PaginationNext,
+ PaginationEllipsis,
+}
\ No newline at end of file
diff --git a/frontend/src/components/ui/progress.tsx b/frontend/src/components/ui/progress.tsx
new file mode 100644
index 0000000..10af7e6
--- /dev/null
+++ b/frontend/src/components/ui/progress.tsx
@@ -0,0 +1,29 @@
+import * as React from "react"
+import * as ProgressPrimitive from "@radix-ui/react-progress"
+
+import { cn } from "@/lib/utils"
+
+function Progress({
+ className,
+ value,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+export { Progress }
diff --git a/frontend/src/components/ui/radio-group.tsx b/frontend/src/components/ui/radio-group.tsx
new file mode 100644
index 0000000..43b43b4
--- /dev/null
+++ b/frontend/src/components/ui/radio-group.tsx
@@ -0,0 +1,42 @@
+import * as React from "react"
+import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
+import { Circle } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+const RadioGroup = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => {
+ return (
+
+ )
+})
+RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
+
+const RadioGroupItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => {
+ return (
+
+
+
+
+
+ )
+})
+RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
+
+export { RadioGroup, RadioGroupItem }
diff --git a/frontend/src/components/ui/select.tsx b/frontend/src/components/ui/select.tsx
new file mode 100644
index 0000000..d34798f
--- /dev/null
+++ b/frontend/src/components/ui/select.tsx
@@ -0,0 +1,185 @@
+import * as React from "react"
+import * as SelectPrimitive from "@radix-ui/react-select"
+import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+function Select({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function SelectGroup({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function SelectValue({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function SelectTrigger({
+ className,
+ size = "default",
+ children,
+ ...props
+}: React.ComponentProps & {
+ size?: "sm" | "default"
+}) {
+ return (
+
+ {children}
+
+
+
+
+ )
+}
+
+function SelectContent({
+ className,
+ children,
+ position = "popper",
+ align = "center",
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+ {children}
+
+
+
+
+ )
+}
+
+function SelectLabel({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function SelectItem({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ )
+}
+
+function SelectSeparator({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function SelectScrollUpButton({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+function SelectScrollDownButton({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+export {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectLabel,
+ SelectScrollDownButton,
+ SelectScrollUpButton,
+ SelectSeparator,
+ SelectTrigger,
+ SelectValue,
+}
diff --git a/frontend/src/components/ui/sonner.tsx b/frontend/src/components/ui/sonner.tsx
new file mode 100644
index 0000000..b983c9a
--- /dev/null
+++ b/frontend/src/components/ui/sonner.tsx
@@ -0,0 +1,46 @@
+import {
+ CircleCheckIcon,
+ InfoIcon,
+ Loader2Icon,
+ OctagonXIcon,
+ TriangleAlertIcon,
+} from "lucide-react"
+import { useTheme } from "next-themes"
+import { Toaster as Sonner, type ToasterProps } from "sonner"
+
+const Toaster = ({ ...props }: ToasterProps) => {
+ const { theme = "system" } = useTheme()
+
+ return (
+ ,
+ info: ,
+ warning: ,
+ error: ,
+ loading: ,
+ }}
+ toastOptions={{
+ classNames: {
+ success: "border-green-500 bg-green-50 text-green-900 dark:bg-green-950 dark:text-green-100 [&>svg]:text-green-500",
+ error: "border-red-500 bg-red-50 text-red-900 dark:bg-red-950 dark:text-red-100 [&>svg]:text-red-500",
+ warning: "border-yellow-500 bg-yellow-50 text-yellow-900 dark:bg-yellow-950 dark:text-yellow-100 [&>svg]:text-yellow-500",
+ info: "border-blue-500 bg-blue-50 text-blue-900 dark:bg-blue-950 dark:text-blue-100 [&>svg]:text-blue-500",
+ },
+ }}
+ style={
+ {
+ "--normal-bg": "var(--popover)",
+ "--normal-text": "var(--popover-foreground)",
+ "--normal-border": "var(--border)",
+ "--border-radius": "var(--radius)",
+ } as React.CSSProperties
+ }
+ {...props}
+ />
+ )
+}
+
+export { Toaster }
diff --git a/frontend/src/components/ui/switch.tsx b/frontend/src/components/ui/switch.tsx
new file mode 100644
index 0000000..6a2b524
--- /dev/null
+++ b/frontend/src/components/ui/switch.tsx
@@ -0,0 +1,31 @@
+"use client"
+
+import * as React from "react"
+import * as SwitchPrimitive from "@radix-ui/react-switch"
+
+import { cn } from "@/lib/utils"
+
+function Switch({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+export { Switch }
diff --git a/frontend/src/components/ui/tabs.tsx b/frontend/src/components/ui/tabs.tsx
new file mode 100644
index 0000000..497ba5e
--- /dev/null
+++ b/frontend/src/components/ui/tabs.tsx
@@ -0,0 +1,66 @@
+"use client"
+
+import * as React from "react"
+import * as TabsPrimitive from "@radix-ui/react-tabs"
+
+import { cn } from "@/lib/utils"
+
+function Tabs({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function TabsList({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function TabsTrigger({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function TabsContent({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export { Tabs, TabsList, TabsTrigger, TabsContent }
diff --git a/frontend/src/components/ui/textarea.tsx b/frontend/src/components/ui/textarea.tsx
new file mode 100644
index 0000000..7f21b5e
--- /dev/null
+++ b/frontend/src/components/ui/textarea.tsx
@@ -0,0 +1,18 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
+ return (
+
+ )
+}
+
+export { Textarea }
diff --git a/frontend/src/index.css b/frontend/src/index.css
new file mode 100644
index 0000000..c0b222d
--- /dev/null
+++ b/frontend/src/index.css
@@ -0,0 +1,194 @@
+@import "tailwindcss";
+@import "tw-animate-css";
+
+@custom-variant dark (&:is(.dark *));
+
+@theme inline {
+ --radius-sm: calc(var(--radius) - 4px);
+ --radius-md: calc(var(--radius) - 2px);
+ --radius-lg: var(--radius);
+ --radius-xl: calc(var(--radius) + 4px);
+ --color-background: var(--background);
+ --color-foreground: var(--foreground);
+ --color-card: var(--card);
+ --color-card-foreground: var(--card-foreground);
+ --color-popover: var(--popover);
+ --color-popover-foreground: var(--popover-foreground);
+ --color-primary: var(--primary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-secondary: var(--secondary);
+ --color-secondary-foreground: var(--secondary-foreground);
+ --color-muted: var(--muted);
+ --color-muted-foreground: var(--muted-foreground);
+ --color-accent: var(--accent);
+ --color-accent-foreground: var(--accent-foreground);
+ --color-destructive: var(--destructive);
+ --color-border: var(--border);
+ --color-input: var(--input);
+ --color-ring: var(--ring);
+ --color-chart-1: var(--chart-1);
+ --color-chart-2: var(--chart-2);
+ --color-chart-3: var(--chart-3);
+ --color-chart-4: var(--chart-4);
+ --color-chart-5: var(--chart-5);
+ --color-sidebar: var(--sidebar);
+ --color-sidebar-foreground: var(--sidebar-foreground);
+ --color-sidebar-primary: var(--sidebar-primary);
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
+ --color-sidebar-accent: var(--sidebar-accent);
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
+ --color-sidebar-border: var(--sidebar-border);
+ --color-sidebar-ring: var(--sidebar-ring);
+}
+
+:root {
+ --radius: 0.625rem;
+ --background: oklch(1 0 0);
+ --foreground: oklch(0.145 0 0);
+ --card: oklch(1 0 0);
+ --card-foreground: oklch(0.145 0 0);
+ --popover: oklch(1 0 0);
+ --popover-foreground: oklch(0.145 0 0);
+ --primary: oklch(0.205 0 0);
+ --primary-foreground: oklch(0.985 0 0);
+ --secondary: oklch(0.97 0 0);
+ --secondary-foreground: oklch(0.205 0 0);
+ --muted: oklch(0.97 0 0);
+ --muted-foreground: oklch(0.556 0 0);
+ --accent: oklch(0.97 0 0);
+ --accent-foreground: oklch(0.205 0 0);
+ --destructive: oklch(0.577 0.245 27.325);
+ --border: oklch(0.922 0 0);
+ --input: oklch(0.922 0 0);
+ --ring: oklch(0.708 0 0);
+ --chart-1: oklch(0.646 0.222 41.116);
+ --chart-2: oklch(0.6 0.118 184.704);
+ --chart-3: oklch(0.398 0.07 227.392);
+ --chart-4: oklch(0.828 0.189 84.429);
+ --chart-5: oklch(0.769 0.188 70.08);
+ --sidebar: oklch(0.985 0 0);
+ --sidebar-foreground: oklch(0.145 0 0);
+ --sidebar-primary: oklch(0.205 0 0);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.97 0 0);
+ --sidebar-accent-foreground: oklch(0.205 0 0);
+ --sidebar-border: oklch(0.922 0 0);
+ --sidebar-ring: oklch(0.708 0 0);
+}
+
+.dark {
+ --background: oklch(0.145 0 0);
+ --foreground: oklch(0.985 0 0);
+ --card: oklch(0.205 0 0);
+ --card-foreground: oklch(0.985 0 0);
+ --popover: oklch(0.205 0 0);
+ --popover-foreground: oklch(0.985 0 0);
+ --primary: oklch(0.922 0 0);
+ --primary-foreground: oklch(0.205 0 0);
+ --secondary: oklch(0.269 0 0);
+ --secondary-foreground: oklch(0.985 0 0);
+ --muted: oklch(0.269 0 0);
+ --muted-foreground: oklch(0.708 0 0);
+ --accent: oklch(0.269 0 0);
+ --accent-foreground: oklch(0.985 0 0);
+ --destructive: oklch(0.704 0.191 22.216);
+ --border: oklch(1 0 0 / 10%);
+ --input: oklch(1 0 0 / 15%);
+ --ring: oklch(0.556 0 0);
+ --chart-1: oklch(0.488 0.243 264.376);
+ --chart-2: oklch(0.696 0.17 162.48);
+ --chart-3: oklch(0.769 0.188 70.08);
+ --chart-4: oklch(0.627 0.265 303.9);
+ --chart-5: oklch(0.645 0.246 16.439);
+ --sidebar: oklch(0.205 0 0);
+ --sidebar-foreground: oklch(0.985 0 0);
+ --sidebar-primary: oklch(0.488 0.243 264.376);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.269 0 0);
+ --sidebar-accent-foreground: oklch(0.985 0 0);
+ --sidebar-border: oklch(1 0 0 / 10%);
+ --sidebar-ring: oklch(0.556 0 0);
+}
+
+@layer base {
+ * {
+ @apply border-border outline-ring/50;
+ }
+ body {
+ @apply bg-background text-foreground;
+ font-family: "Google Sans Flex", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+ }
+ code, pre, .font-mono {
+ font-family: "Google Sans Code", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
+ }
+}
+
+/* Sonner Toast Styling */
+[data-sonner-toast] {
+ @apply rounded-lg shadow-lg border;
+}
+
+[data-sonner-toast][data-type="success"] {
+ @apply bg-green-50 border-green-200 text-green-900;
+}
+
+[data-sonner-toast][data-type="success"] [data-icon] {
+ @apply text-green-600;
+}
+
+[data-sonner-toast][data-type="error"] {
+ @apply bg-red-50 border-red-200 text-red-900;
+}
+
+[data-sonner-toast][data-type="error"] [data-icon] {
+ @apply text-red-600;
+}
+
+[data-sonner-toast][data-type="warning"] {
+ @apply bg-yellow-50 border-yellow-200 text-yellow-900;
+}
+
+[data-sonner-toast][data-type="warning"] [data-icon] {
+ @apply text-yellow-600;
+}
+
+[data-sonner-toast][data-type="info"] {
+ @apply bg-blue-50 border-blue-200 text-blue-900;
+}
+
+[data-sonner-toast][data-type="info"] [data-icon] {
+ @apply text-blue-600;
+}
+
+/* Dark mode toast styling */
+.dark [data-sonner-toast][data-type="success"] {
+ @apply bg-green-950 border-green-800 text-green-100;
+}
+
+.dark [data-sonner-toast][data-type="success"] [data-icon] {
+ @apply text-green-400;
+}
+
+.dark [data-sonner-toast][data-type="error"] {
+ @apply bg-red-950 border-red-800 text-red-100;
+}
+
+.dark [data-sonner-toast][data-type="error"] [data-icon] {
+ @apply text-red-400;
+}
+
+.dark [data-sonner-toast][data-type="warning"] {
+ @apply bg-yellow-950 border-yellow-800 text-yellow-100;
+}
+
+.dark [data-sonner-toast][data-type="warning"] [data-icon] {
+ @apply text-yellow-400;
+}
+
+.dark [data-sonner-toast][data-type="info"] {
+ @apply bg-blue-950 border-blue-800 text-blue-100;
+}
+
+.dark [data-sonner-toast][data-type="info"] [data-icon] {
+ @apply text-blue-400;
+}
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
new file mode 100644
index 0000000..4f3191f
--- /dev/null
+++ b/frontend/src/lib/api.ts
@@ -0,0 +1,41 @@
+import type {
+ SpotifyMetadataResponse,
+ DownloadRequest,
+ DownloadResponse,
+ HealthResponse,
+} from "@/types/api";
+import { GetSpotifyMetadata, DownloadTrack } from "../../wailsjs/go/main/App";
+import { main } from "../../wailsjs/go/models";
+
+export async function fetchSpotifyMetadata(
+ url: string,
+ batch: boolean = true,
+ delay: number = 1.0,
+ timeout: number = 300.0
+): Promise {
+ const req = new main.SpotifyMetadataRequest({
+ url,
+ batch,
+ delay,
+ timeout,
+ });
+
+ const jsonString = await GetSpotifyMetadata(req);
+ return JSON.parse(jsonString);
+}
+
+export async function downloadTrack(
+ request: DownloadRequest
+): Promise {
+ const req = new main.DownloadRequest(request);
+ return await DownloadTrack(req);
+}
+
+export async function checkHealth(): Promise {
+ // For Wails, we can just return a simple health check
+ // since the app is running locally
+ return {
+ status: "ok",
+ time: new Date().toISOString(),
+ };
+}
diff --git a/frontend/src/lib/settings.ts b/frontend/src/lib/settings.ts
new file mode 100644
index 0000000..ef87a70
--- /dev/null
+++ b/frontend/src/lib/settings.ts
@@ -0,0 +1,74 @@
+import { GetDefaults } from "../../wailsjs/go/main/App";
+
+export interface Settings {
+ downloadPath: string;
+ downloader: "auto" | "deezer" | "tidal";
+ theme: string;
+ darkMode: boolean;
+ filenameFormat: "title-artist" | "artist-title" | "title";
+ artistSubfolder: boolean;
+ albumSubfolder: boolean;
+ trackNumber: boolean;
+}
+
+const DEFAULT_SETTINGS: Settings = {
+ downloadPath: "",
+ downloader: "auto",
+ theme: "yellow",
+ darkMode: true,
+ filenameFormat: "title-artist",
+ artistSubfolder: false,
+ albumSubfolder: false,
+ trackNumber: false,
+};
+
+async function fetchDefaultPath(): Promise {
+ try {
+ const data = await GetDefaults();
+ return data.downloadPath || "C:\\Users\\Public\\Music";
+ } catch (error) {
+ console.error("Failed to fetch default path:", error);
+ }
+ return "C:\\Users\\Public\\Music";
+}
+
+const SETTINGS_KEY = "spotiflac-settings";
+
+export function getSettings(): Settings {
+ try {
+ const stored = localStorage.getItem(SETTINGS_KEY);
+ if (stored) {
+ const parsed = JSON.parse(stored);
+ return { ...DEFAULT_SETTINGS, ...parsed };
+ }
+ } catch (error) {
+ console.error("Failed to load settings:", error);
+ }
+ return DEFAULT_SETTINGS;
+}
+
+export async function getSettingsWithDefaults(): Promise {
+ const settings = getSettings();
+
+ // If downloadPath is empty, fetch from backend
+ if (!settings.downloadPath) {
+ settings.downloadPath = await fetchDefaultPath();
+ }
+
+ return settings;
+}
+
+export function saveSettings(settings: Settings): void {
+ try {
+ localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
+ } catch (error) {
+ console.error("Failed to save settings:", error);
+ }
+}
+
+export function updateSettings(partial: Partial): Settings {
+ const current = getSettings();
+ const updated = { ...current, ...partial };
+ saveSettings(updated);
+ return updated;
+}
diff --git a/frontend/src/lib/themes.ts b/frontend/src/lib/themes.ts
new file mode 100644
index 0000000..e4f548a
--- /dev/null
+++ b/frontend/src/lib/themes.ts
@@ -0,0 +1,470 @@
+export interface Theme {
+ name: string;
+ label: string;
+ cssVars: {
+ light: Record;
+ dark: Record;
+ };
+}
+
+export const themes: Theme[] = [
+ {
+ name: "neutral",
+ label: "Default",
+ cssVars: {
+ light: {
+ background: "oklch(1 0 0)",
+ foreground: "oklch(0.145 0 0)",
+ card: "oklch(1 0 0)",
+ "card-foreground": "oklch(0.145 0 0)",
+ popover: "oklch(1 0 0)",
+ "popover-foreground": "oklch(0.145 0 0)",
+ primary: "oklch(0.205 0 0)",
+ "primary-foreground": "oklch(0.985 0 0)",
+ secondary: "oklch(0.97 0 0)",
+ "secondary-foreground": "oklch(0.205 0 0)",
+ muted: "oklch(0.97 0 0)",
+ "muted-foreground": "oklch(0.556 0 0)",
+ accent: "oklch(0.97 0 0)",
+ "accent-foreground": "oklch(0.205 0 0)",
+ destructive: "oklch(0.577 0.245 27.325)",
+ border: "oklch(0.922 0 0)",
+ input: "oklch(0.922 0 0)",
+ ring: "oklch(0.708 0 0)",
+ "chart-1": "oklch(0.646 0.222 41.116)",
+ "chart-2": "oklch(0.6 0.118 184.704)",
+ "chart-3": "oklch(0.398 0.07 227.392)",
+ "chart-4": "oklch(0.828 0.189 84.429)",
+ "chart-5": "oklch(0.769 0.188 70.08)",
+ },
+ dark: {
+ background: "oklch(0.145 0 0)",
+ foreground: "oklch(0.985 0 0)",
+ card: "oklch(0.205 0 0)",
+ "card-foreground": "oklch(0.985 0 0)",
+ popover: "oklch(0.205 0 0)",
+ "popover-foreground": "oklch(0.985 0 0)",
+ primary: "oklch(0.922 0 0)",
+ "primary-foreground": "oklch(0.205 0 0)",
+ secondary: "oklch(0.269 0 0)",
+ "secondary-foreground": "oklch(0.985 0 0)",
+ muted: "oklch(0.269 0 0)",
+ "muted-foreground": "oklch(0.708 0 0)",
+ accent: "oklch(0.269 0 0)",
+ "accent-foreground": "oklch(0.985 0 0)",
+ destructive: "oklch(0.704 0.191 22.216)",
+ border: "oklch(1 0 0 / 10%)",
+ input: "oklch(1 0 0 / 15%)",
+ ring: "oklch(0.556 0 0)",
+ "chart-1": "oklch(0.488 0.243 264.376)",
+ "chart-2": "oklch(0.696 0.17 162.48)",
+ "chart-3": "oklch(0.769 0.188 70.08)",
+ "chart-4": "oklch(0.627 0.265 303.9)",
+ "chart-5": "oklch(0.645 0.246 16.439)",
+ },
+ },
+ },
+ {
+ name: "blue",
+ label: "Blue",
+ cssVars: {
+ light: {
+ background: "oklch(1 0 0)",
+ foreground: "oklch(0.141 0.005 285.823)",
+ card: "oklch(1 0 0)",
+ "card-foreground": "oklch(0.141 0.005 285.823)",
+ popover: "oklch(1 0 0)",
+ "popover-foreground": "oklch(0.141 0.005 285.823)",
+ primary: "oklch(0.488 0.243 264.376)",
+ "primary-foreground": "oklch(0.97 0.014 254.604)",
+ secondary: "oklch(0.967 0.001 286.375)",
+ "secondary-foreground": "oklch(0.21 0.006 285.885)",
+ muted: "oklch(0.967 0.001 286.375)",
+ "muted-foreground": "oklch(0.552 0.016 285.938)",
+ accent: "oklch(0.967 0.001 286.375)",
+ "accent-foreground": "oklch(0.21 0.006 285.885)",
+ destructive: "oklch(0.577 0.245 27.325)",
+ border: "oklch(0.92 0.004 286.32)",
+ input: "oklch(0.92 0.004 286.32)",
+ ring: "oklch(0.708 0 0)",
+ "chart-1": "oklch(0.809 0.105 251.813)",
+ "chart-2": "oklch(0.623 0.214 259.815)",
+ "chart-3": "oklch(0.546 0.245 262.881)",
+ "chart-4": "oklch(0.488 0.243 264.376)",
+ "chart-5": "oklch(0.424 0.199 265.638)",
+ },
+ dark: {
+ background: "oklch(0.141 0.005 285.823)",
+ foreground: "oklch(0.985 0 0)",
+ card: "oklch(0.21 0.006 285.885)",
+ "card-foreground": "oklch(0.985 0 0)",
+ popover: "oklch(0.21 0.006 285.885)",
+ "popover-foreground": "oklch(0.985 0 0)",
+ primary: "oklch(0.488 0.243 264.376)",
+ "primary-foreground": "oklch(0.97 0.014 254.604)",
+ secondary: "oklch(0.274 0.006 286.033)",
+ "secondary-foreground": "oklch(0.985 0 0)",
+ muted: "oklch(0.274 0.006 286.033)",
+ "muted-foreground": "oklch(0.705 0.015 286.067)",
+ accent: "oklch(0.274 0.006 286.033)",
+ "accent-foreground": "oklch(0.985 0 0)",
+ destructive: "oklch(0.704 0.191 22.216)",
+ border: "oklch(1 0 0 / 10%)",
+ input: "oklch(1 0 0 / 15%)",
+ ring: "oklch(0.556 0 0)",
+ "chart-1": "oklch(0.809 0.105 251.813)",
+ "chart-2": "oklch(0.623 0.214 259.815)",
+ "chart-3": "oklch(0.546 0.245 262.881)",
+ "chart-4": "oklch(0.488 0.243 264.376)",
+ "chart-5": "oklch(0.424 0.199 265.638)",
+ },
+ },
+ },
+ {
+ name: "green",
+ label: "Green",
+ cssVars: {
+ light: {
+ background: "oklch(1 0 0)",
+ foreground: "oklch(0.141 0.005 285.823)",
+ card: "oklch(1 0 0)",
+ "card-foreground": "oklch(0.141 0.005 285.823)",
+ popover: "oklch(1 0 0)",
+ "popover-foreground": "oklch(0.141 0.005 285.823)",
+ primary: "oklch(0.648 0.2 131.684)",
+ "primary-foreground": "oklch(0.986 0.031 120.757)",
+ secondary: "oklch(0.967 0.001 286.375)",
+ "secondary-foreground": "oklch(0.21 0.006 285.885)",
+ muted: "oklch(0.967 0.001 286.375)",
+ "muted-foreground": "oklch(0.552 0.016 285.938)",
+ accent: "oklch(0.967 0.001 286.375)",
+ "accent-foreground": "oklch(0.21 0.006 285.885)",
+ destructive: "oklch(0.577 0.245 27.325)",
+ border: "oklch(0.92 0.004 286.32)",
+ input: "oklch(0.92 0.004 286.32)",
+ ring: "oklch(0.841 0.238 128.85)",
+ "chart-1": "oklch(0.871 0.15 154.449)",
+ "chart-2": "oklch(0.723 0.219 149.579)",
+ "chart-3": "oklch(0.627 0.194 149.214)",
+ "chart-4": "oklch(0.527 0.154 150.069)",
+ "chart-5": "oklch(0.448 0.119 151.328)",
+ },
+ dark: {
+ background: "oklch(0.141 0.005 285.823)",
+ foreground: "oklch(0.985 0 0)",
+ card: "oklch(0.21 0.006 285.885)",
+ "card-foreground": "oklch(0.985 0 0)",
+ popover: "oklch(0.21 0.006 285.885)",
+ "popover-foreground": "oklch(0.985 0 0)",
+ primary: "oklch(0.648 0.2 131.684)",
+ "primary-foreground": "oklch(0.986 0.031 120.757)",
+ secondary: "oklch(0.274 0.006 286.033)",
+ "secondary-foreground": "oklch(0.985 0 0)",
+ muted: "oklch(0.274 0.006 286.033)",
+ "muted-foreground": "oklch(0.705 0.015 286.067)",
+ accent: "oklch(0.274 0.006 286.033)",
+ "accent-foreground": "oklch(0.985 0 0)",
+ destructive: "oklch(0.704 0.191 22.216)",
+ border: "oklch(1 0 0 / 10%)",
+ input: "oklch(1 0 0 / 15%)",
+ ring: "oklch(0.405 0.101 131.063)",
+ "chart-1": "oklch(0.871 0.15 154.449)",
+ "chart-2": "oklch(0.723 0.219 149.579)",
+ "chart-3": "oklch(0.627 0.194 149.214)",
+ "chart-4": "oklch(0.527 0.154 150.069)",
+ "chart-5": "oklch(0.448 0.119 151.328)",
+ },
+ },
+ },
+ {
+ name: "orange",
+ label: "Orange",
+ cssVars: {
+ light: {
+ background: "oklch(1 0 0)",
+ foreground: "oklch(0.141 0.005 285.823)",
+ card: "oklch(1 0 0)",
+ "card-foreground": "oklch(0.141 0.005 285.823)",
+ popover: "oklch(1 0 0)",
+ "popover-foreground": "oklch(0.141 0.005 285.823)",
+ primary: "oklch(0.646 0.222 41.116)",
+ "primary-foreground": "oklch(0.98 0.016 73.684)",
+ secondary: "oklch(0.967 0.001 286.375)",
+ "secondary-foreground": "oklch(0.21 0.006 285.885)",
+ muted: "oklch(0.967 0.001 286.375)",
+ "muted-foreground": "oklch(0.552 0.016 285.938)",
+ accent: "oklch(0.967 0.001 286.375)",
+ "accent-foreground": "oklch(0.21 0.006 285.885)",
+ destructive: "oklch(0.577 0.245 27.325)",
+ border: "oklch(0.92 0.004 286.32)",
+ input: "oklch(0.92 0.004 286.32)",
+ ring: "oklch(0.75 0.183 55.934)",
+ "chart-1": "oklch(0.837 0.128 66.29)",
+ "chart-2": "oklch(0.705 0.213 47.604)",
+ "chart-3": "oklch(0.646 0.222 41.116)",
+ "chart-4": "oklch(0.553 0.195 38.402)",
+ "chart-5": "oklch(0.47 0.157 37.304)",
+ },
+ dark: {
+ background: "oklch(0.141 0.005 285.823)",
+ foreground: "oklch(0.985 0 0)",
+ card: "oklch(0.21 0.006 285.885)",
+ "card-foreground": "oklch(0.985 0 0)",
+ popover: "oklch(0.21 0.006 285.885)",
+ "popover-foreground": "oklch(0.985 0 0)",
+ primary: "oklch(0.705 0.213 47.604)",
+ "primary-foreground": "oklch(0.98 0.016 73.684)",
+ secondary: "oklch(0.274 0.006 286.033)",
+ "secondary-foreground": "oklch(0.985 0 0)",
+ muted: "oklch(0.274 0.006 286.033)",
+ "muted-foreground": "oklch(0.705 0.015 286.067)",
+ accent: "oklch(0.274 0.006 286.033)",
+ "accent-foreground": "oklch(0.985 0 0)",
+ destructive: "oklch(0.704 0.191 22.216)",
+ border: "oklch(1 0 0 / 10%)",
+ input: "oklch(1 0 0 / 15%)",
+ ring: "oklch(0.408 0.123 38.172)",
+ "chart-1": "oklch(0.837 0.128 66.29)",
+ "chart-2": "oklch(0.705 0.213 47.604)",
+ "chart-3": "oklch(0.646 0.222 41.116)",
+ "chart-4": "oklch(0.553 0.195 38.402)",
+ "chart-5": "oklch(0.47 0.157 37.304)",
+ },
+ },
+ },
+ {
+ name: "red",
+ label: "Red",
+ cssVars: {
+ light: {
+ background: "oklch(1 0 0)",
+ foreground: "oklch(0.141 0.005 285.823)",
+ card: "oklch(1 0 0)",
+ "card-foreground": "oklch(0.141 0.005 285.823)",
+ popover: "oklch(1 0 0)",
+ "popover-foreground": "oklch(0.141 0.005 285.823)",
+ primary: "oklch(0.577 0.245 27.325)",
+ "primary-foreground": "oklch(0.971 0.013 17.38)",
+ secondary: "oklch(0.967 0.001 286.375)",
+ "secondary-foreground": "oklch(0.21 0.006 285.885)",
+ muted: "oklch(0.967 0.001 286.375)",
+ "muted-foreground": "oklch(0.552 0.016 285.938)",
+ accent: "oklch(0.967 0.001 286.375)",
+ "accent-foreground": "oklch(0.21 0.006 285.885)",
+ destructive: "oklch(0.577 0.245 27.325)",
+ border: "oklch(0.92 0.004 286.32)",
+ input: "oklch(0.92 0.004 286.32)",
+ ring: "oklch(0.704 0.191 22.216)",
+ "chart-1": "oklch(0.808 0.114 19.571)",
+ "chart-2": "oklch(0.637 0.237 25.331)",
+ "chart-3": "oklch(0.577 0.245 27.325)",
+ "chart-4": "oklch(0.505 0.213 27.518)",
+ "chart-5": "oklch(0.444 0.177 26.899)",
+ },
+ dark: {
+ background: "oklch(0.141 0.005 285.823)",
+ foreground: "oklch(0.985 0 0)",
+ card: "oklch(0.21 0.006 285.885)",
+ "card-foreground": "oklch(0.985 0 0)",
+ popover: "oklch(0.21 0.006 285.885)",
+ "popover-foreground": "oklch(0.985 0 0)",
+ primary: "oklch(0.637 0.237 25.331)",
+ "primary-foreground": "oklch(0.971 0.013 17.38)",
+ secondary: "oklch(0.274 0.006 286.033)",
+ "secondary-foreground": "oklch(0.985 0 0)",
+ muted: "oklch(0.274 0.006 286.033)",
+ "muted-foreground": "oklch(0.705 0.015 286.067)",
+ accent: "oklch(0.274 0.006 286.033)",
+ "accent-foreground": "oklch(0.985 0 0)",
+ destructive: "oklch(0.704 0.191 22.216)",
+ border: "oklch(1 0 0 / 10%)",
+ input: "oklch(1 0 0 / 15%)",
+ ring: "oklch(0.396 0.141 25.723)",
+ "chart-1": "oklch(0.808 0.114 19.571)",
+ "chart-2": "oklch(0.637 0.237 25.331)",
+ "chart-3": "oklch(0.577 0.245 27.325)",
+ "chart-4": "oklch(0.505 0.213 27.518)",
+ "chart-5": "oklch(0.444 0.177 26.899)",
+ },
+ },
+ },
+ {
+ name: "rose",
+ label: "Rose",
+ cssVars: {
+ light: {
+ background: "oklch(1 0 0)",
+ foreground: "oklch(0.141 0.005 285.823)",
+ card: "oklch(1 0 0)",
+ "card-foreground": "oklch(0.141 0.005 285.823)",
+ popover: "oklch(1 0 0)",
+ "popover-foreground": "oklch(0.141 0.005 285.823)",
+ primary: "oklch(0.586 0.253 17.585)",
+ "primary-foreground": "oklch(0.969 0.015 12.422)",
+ secondary: "oklch(0.967 0.001 286.375)",
+ "secondary-foreground": "oklch(0.21 0.006 285.885)",
+ muted: "oklch(0.967 0.001 286.375)",
+ "muted-foreground": "oklch(0.552 0.016 285.938)",
+ accent: "oklch(0.967 0.001 286.375)",
+ "accent-foreground": "oklch(0.21 0.006 285.885)",
+ destructive: "oklch(0.577 0.245 27.325)",
+ border: "oklch(0.92 0.004 286.32)",
+ input: "oklch(0.92 0.004 286.32)",
+ ring: "oklch(0.712 0.194 13.428)",
+ "chart-1": "oklch(0.81 0.117 11.638)",
+ "chart-2": "oklch(0.645 0.246 16.439)",
+ "chart-3": "oklch(0.586 0.253 17.585)",
+ "chart-4": "oklch(0.514 0.222 16.935)",
+ "chart-5": "oklch(0.455 0.188 13.697)",
+ },
+ dark: {
+ background: "oklch(0.141 0.005 285.823)",
+ foreground: "oklch(0.985 0 0)",
+ card: "oklch(0.21 0.006 285.885)",
+ "card-foreground": "oklch(0.985 0 0)",
+ popover: "oklch(0.21 0.006 285.885)",
+ "popover-foreground": "oklch(0.985 0 0)",
+ primary: "oklch(0.645 0.246 16.439)",
+ "primary-foreground": "oklch(0.969 0.015 12.422)",
+ secondary: "oklch(0.274 0.006 286.033)",
+ "secondary-foreground": "oklch(0.985 0 0)",
+ muted: "oklch(0.274 0.006 286.033)",
+ "muted-foreground": "oklch(0.705 0.015 286.067)",
+ accent: "oklch(0.274 0.006 286.033)",
+ "accent-foreground": "oklch(0.985 0 0)",
+ destructive: "oklch(0.704 0.191 22.216)",
+ border: "oklch(1 0 0 / 10%)",
+ input: "oklch(1 0 0 / 15%)",
+ ring: "oklch(0.41 0.159 10.272)",
+ "chart-1": "oklch(0.81 0.117 11.638)",
+ "chart-2": "oklch(0.645 0.246 16.439)",
+ "chart-3": "oklch(0.586 0.253 17.585)",
+ "chart-4": "oklch(0.514 0.222 16.935)",
+ "chart-5": "oklch(0.455 0.188 13.697)",
+ },
+ },
+ },
+ {
+ name: "violet",
+ label: "Violet",
+ cssVars: {
+ light: {
+ background: "oklch(1 0 0)",
+ foreground: "oklch(0.141 0.005 285.823)",
+ card: "oklch(1 0 0)",
+ "card-foreground": "oklch(0.141 0.005 285.823)",
+ popover: "oklch(1 0 0)",
+ "popover-foreground": "oklch(0.141 0.005 285.823)",
+ primary: "oklch(0.541 0.281 293.009)",
+ "primary-foreground": "oklch(0.969 0.016 293.756)",
+ secondary: "oklch(0.967 0.001 286.375)",
+ "secondary-foreground": "oklch(0.21 0.006 285.885)",
+ muted: "oklch(0.967 0.001 286.375)",
+ "muted-foreground": "oklch(0.552 0.016 285.938)",
+ accent: "oklch(0.967 0.001 286.375)",
+ "accent-foreground": "oklch(0.21 0.006 285.885)",
+ destructive: "oklch(0.577 0.245 27.325)",
+ border: "oklch(0.92 0.004 286.32)",
+ input: "oklch(0.92 0.004 286.32)",
+ ring: "oklch(0.702 0.183 293.541)",
+ "chart-1": "oklch(0.811 0.111 293.571)",
+ "chart-2": "oklch(0.606 0.25 292.717)",
+ "chart-3": "oklch(0.541 0.281 293.009)",
+ "chart-4": "oklch(0.491 0.27 292.581)",
+ "chart-5": "oklch(0.432 0.232 292.759)",
+ },
+ dark: {
+ background: "oklch(0.141 0.005 285.823)",
+ foreground: "oklch(0.985 0 0)",
+ card: "oklch(0.21 0.006 285.885)",
+ "card-foreground": "oklch(0.985 0 0)",
+ popover: "oklch(0.21 0.006 285.885)",
+ "popover-foreground": "oklch(0.985 0 0)",
+ primary: "oklch(0.606 0.25 292.717)",
+ "primary-foreground": "oklch(0.969 0.016 293.756)",
+ secondary: "oklch(0.274 0.006 286.033)",
+ "secondary-foreground": "oklch(0.985 0 0)",
+ muted: "oklch(0.274 0.006 286.033)",
+ "muted-foreground": "oklch(0.705 0.015 286.067)",
+ accent: "oklch(0.274 0.006 286.033)",
+ "accent-foreground": "oklch(0.985 0 0)",
+ destructive: "oklch(0.704 0.191 22.216)",
+ border: "oklch(1 0 0 / 10%)",
+ input: "oklch(1 0 0 / 15%)",
+ ring: "oklch(0.38 0.189 293.745)",
+ "chart-1": "oklch(0.811 0.111 293.571)",
+ "chart-2": "oklch(0.606 0.25 292.717)",
+ "chart-3": "oklch(0.541 0.281 293.009)",
+ "chart-4": "oklch(0.491 0.27 292.581)",
+ "chart-5": "oklch(0.432 0.232 292.759)",
+ },
+ },
+ },
+ {
+ name: "yellow",
+ label: "Yellow",
+ cssVars: {
+ light: {
+ background: "oklch(1 0 0)",
+ foreground: "oklch(0.141 0.005 285.823)",
+ card: "oklch(1 0 0)",
+ "card-foreground": "oklch(0.141 0.005 285.823)",
+ popover: "oklch(1 0 0)",
+ "popover-foreground": "oklch(0.141 0.005 285.823)",
+ primary: "oklch(0.852 0.199 91.936)",
+ "primary-foreground": "oklch(0.421 0.095 57.708)",
+ secondary: "oklch(0.967 0.001 286.375)",
+ "secondary-foreground": "oklch(0.21 0.006 285.885)",
+ muted: "oklch(0.967 0.001 286.375)",
+ "muted-foreground": "oklch(0.552 0.016 285.938)",
+ accent: "oklch(0.967 0.001 286.375)",
+ "accent-foreground": "oklch(0.21 0.006 285.885)",
+ destructive: "oklch(0.577 0.245 27.325)",
+ border: "oklch(0.92 0.004 286.32)",
+ input: "oklch(0.92 0.004 286.32)",
+ ring: "oklch(0.852 0.199 91.936)",
+ "chart-1": "oklch(0.905 0.182 98.111)",
+ "chart-2": "oklch(0.795 0.184 86.047)",
+ "chart-3": "oklch(0.681 0.162 75.834)",
+ "chart-4": "oklch(0.554 0.135 66.442)",
+ "chart-5": "oklch(0.476 0.114 61.907)",
+ },
+ dark: {
+ background: "oklch(0.141 0.005 285.823)",
+ foreground: "oklch(0.985 0 0)",
+ card: "oklch(0.21 0.006 285.885)",
+ "card-foreground": "oklch(0.985 0 0)",
+ popover: "oklch(0.21 0.006 285.885)",
+ "popover-foreground": "oklch(0.985 0 0)",
+ primary: "oklch(0.795 0.184 86.047)",
+ "primary-foreground": "oklch(0.421 0.095 57.708)",
+ secondary: "oklch(0.274 0.006 286.033)",
+ "secondary-foreground": "oklch(0.985 0 0)",
+ muted: "oklch(0.274 0.006 286.033)",
+ "muted-foreground": "oklch(0.705 0.015 286.067)",
+ accent: "oklch(0.274 0.006 286.033)",
+ "accent-foreground": "oklch(0.985 0 0)",
+ destructive: "oklch(0.704 0.191 22.216)",
+ border: "oklch(1 0 0 / 10%)",
+ input: "oklch(1 0 0 / 15%)",
+ ring: "oklch(0.421 0.095 57.708)",
+ "chart-1": "oklch(0.905 0.182 98.111)",
+ "chart-2": "oklch(0.795 0.184 86.047)",
+ "chart-3": "oklch(0.681 0.162 75.834)",
+ "chart-4": "oklch(0.554 0.135 66.442)",
+ "chart-5": "oklch(0.476 0.114 61.907)",
+ },
+ },
+ },
+];
+
+export function applyTheme(themeName: string) {
+ const theme = themes.find((t) => t.name === themeName) || themes[0];
+ const root = document.documentElement;
+ const isDark = root.classList.contains("dark");
+ const vars = isDark ? theme.cssVars.dark : theme.cssVars.light;
+
+ Object.entries(vars).forEach(([key, value]) => {
+ root.style.setProperty(`--${key}`, value);
+ });
+}
diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts
new file mode 100644
index 0000000..bd0c391
--- /dev/null
+++ b/frontend/src/lib/utils.ts
@@ -0,0 +1,6 @@
+import { clsx, type ClassValue } from "clsx"
+import { twMerge } from "tailwind-merge"
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs))
+}
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
new file mode 100644
index 0000000..7aad6c5
--- /dev/null
+++ b/frontend/src/main.tsx
@@ -0,0 +1,12 @@
+import { StrictMode } from 'react'
+import { createRoot } from 'react-dom/client'
+import './index.css'
+import App from './App.tsx'
+import { Toaster } from '@/components/ui/sonner'
+
+createRoot(document.getElementById('root')!).render(
+
+
+
+ ,
+)
diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts
new file mode 100644
index 0000000..c09a425
--- /dev/null
+++ b/frontend/src/types/api.ts
@@ -0,0 +1,118 @@
+export interface TrackMetadata {
+ artists: string;
+ name: string;
+ album_name: string;
+ duration_ms: number;
+ images: string;
+ release_date: string;
+ track_number: number;
+ external_urls: string;
+ isrc: string;
+ album_type?: string;
+}
+
+export interface TrackResponse {
+ track: TrackMetadata;
+}
+
+export interface AlbumInfo {
+ total_tracks: number;
+ name: string;
+ release_date: string;
+ artists: string;
+ images: string;
+ batch?: string;
+}
+
+export interface AlbumResponse {
+ album_info: AlbumInfo;
+ track_list: TrackMetadata[];
+}
+
+export interface PlaylistInfo {
+ tracks: {
+ total: number;
+ };
+ followers: {
+ total: number;
+ };
+ owner: {
+ display_name: string;
+ name: string;
+ images: string;
+ };
+ batch?: string;
+}
+
+export interface PlaylistResponse {
+ playlist_info: PlaylistInfo;
+ track_list: TrackMetadata[];
+}
+
+export interface ArtistInfo {
+ name: string;
+ followers: number;
+ genres: string[];
+ images: string;
+ external_urls: string;
+ discography_type: string;
+ total_albums: number;
+ batch?: string;
+}
+
+export interface DiscographyAlbum {
+ id: string;
+ name: string;
+ album_type: string;
+ release_date: string;
+ total_tracks: number;
+ artists: string;
+ images: string;
+ external_urls: string;
+}
+
+export interface ArtistDiscographyResponse {
+ artist_info: ArtistInfo;
+ album_list: DiscographyAlbum[];
+ track_list: TrackMetadata[];
+}
+
+export interface ArtistResponse {
+ artist: {
+ name: string;
+ followers: number;
+ genres: string[];
+ images: string;
+ external_urls: string;
+ popularity: number;
+ };
+}
+
+export type SpotifyMetadataResponse =
+ | TrackResponse
+ | AlbumResponse
+ | PlaylistResponse
+ | ArtistDiscographyResponse
+ | ArtistResponse;
+
+export interface DownloadRequest {
+ isrc: string;
+ service: "deezer" | "tidal";
+ query?: string;
+ api_url?: string;
+ output_dir?: string;
+ audio_format?: string;
+ folder_name?: string;
+}
+
+export interface DownloadResponse {
+ success: boolean;
+ message: string;
+ file?: string;
+ error?: string;
+}
+
+export interface HealthResponse {
+ status: string;
+ time: string;
+}
diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json
new file mode 100644
index 0000000..c9c69a1
--- /dev/null
+++ b/frontend/tsconfig.app.json
@@ -0,0 +1,36 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
+ "target": "ES2022",
+ "useDefineForClassFields": true,
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "types": ["vite/client"],
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "jsx": "react-jsx",
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "erasableSyntaxOnly": false,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedSideEffectImports": true,
+
+ /* Path Mapping */
+ "baseUrl": ".",
+ "paths": {
+ "@/*": [
+ "./src/*"
+ ]
+ }
+ },
+ "include": ["src"]
+}
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
new file mode 100644
index 0000000..fec8c8e
--- /dev/null
+++ b/frontend/tsconfig.json
@@ -0,0 +1,13 @@
+{
+ "files": [],
+ "references": [
+ { "path": "./tsconfig.app.json" },
+ { "path": "./tsconfig.node.json" }
+ ],
+ "compilerOptions": {
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ }
+}
diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json
new file mode 100644
index 0000000..8a67f62
--- /dev/null
+++ b/frontend/tsconfig.node.json
@@ -0,0 +1,26 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
+ "target": "ES2023",
+ "lib": ["ES2023"],
+ "module": "ESNext",
+ "types": ["node"],
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "erasableSyntaxOnly": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedSideEffectImports": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
new file mode 100644
index 0000000..79a65e0
--- /dev/null
+++ b/frontend/vite.config.ts
@@ -0,0 +1,14 @@
+import path from "path"
+import tailwindcss from "@tailwindcss/vite"
+import react from "@vitejs/plugin-react"
+import { defineConfig } from "vite"
+
+// https://vite.dev/config/
+export default defineConfig({
+ plugins: [react(), tailwindcss()],
+ resolve: {
+ alias: {
+ "@": path.resolve(__dirname, "./src"),
+ },
+ },
+})
diff --git a/getMetadata.py b/getMetadata.py
deleted file mode 100644
index 3132535..0000000
--- a/getMetadata.py
+++ /dev/null
@@ -1,740 +0,0 @@
-from time import sleep
-from urllib.parse import urlparse, parse_qs
-from pathlib import Path
-import requests
-import json
-import time
-import pyotp
-import base64
-from random import randrange
-from typing import Dict, Any, List, Tuple
-
-# https://github.com/visagenull/Spotify-Free
-def get_random_user_agent():
- return f"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_{randrange(11, 15)}_{randrange(4, 9)}) AppleWebKit/{randrange(530, 537)}.{randrange(30, 37)} (KHTML, like Gecko) Chrome/{randrange(80, 105)}.0.{randrange(3000, 4500)}.{randrange(60, 125)} Safari/{randrange(530, 537)}.{randrange(30, 36)}"
-
-# https://github.com/xyloflake/spot-secrets-go
-def generate_totp():
- local_path = Path.home() / ".spotify-secret" / "secretBytes.json"
- used_local = False
-
- try:
- url = "https://raw.githubusercontent.com/afkarxyz/secretBytes/refs/heads/main/secrets/secretBytes.json"
- resp = requests.get(url, timeout=10)
- if resp.status_code != 200:
- raise Exception(f"GitHub fetch failed with status: {resp.status_code}")
- secrets_list = resp.json()
- except Exception as github_error:
- try:
- if local_path.exists():
- with open(local_path, 'r') as f:
- secrets_list = json.load(f)
- used_local = True
- else:
- raise Exception(f"GitHub failed ({github_error}) and no local file found at {local_path}")
- except Exception as local_error:
- raise Exception(f"Failed to fetch secrets from both GitHub and local: {local_error}")
-
- try:
- latest_entry = max(secrets_list, key=lambda x: x["version"])
- version = latest_entry["version"]
- secret_cipher = latest_entry["secret"]
- except Exception as e:
- raise Exception(f"Failed to process secrets: {str(e)}")
-
- processed = [byte ^ ((i % 33) + 9) for i, byte in enumerate(secret_cipher)]
- processed_str = "".join(map(str, processed))
- utf8_bytes = processed_str.encode('utf-8')
- hex_str = utf8_bytes.hex()
- secret_bytes = bytes.fromhex(hex_str)
- b32_secret = base64.b32encode(secret_bytes).decode('utf-8')
- totp = pyotp.TOTP(b32_secret)
-
- headers = {
- "Host": "open.spotify.com",
- "User-Agent": get_random_user_agent(),
- "Accept": "*/*",
- }
-
- try:
- resp = requests.get("https://open.spotify.com/api/server-time", headers=headers, timeout=10)
- if resp.status_code != 200:
- raise Exception(f"Failed to get server time. Status code: {resp.status_code}")
- data = resp.json()
- server_time = data.get("serverTime")
- if server_time is None:
- raise Exception("Failed to fetch server time from Spotify")
- return totp, server_time, version
- except Exception as e:
- raise Exception(f"Error getting server time: {str(e)}")
-
-token_url = 'https://open.spotify.com/api/token'
-playlist_base_url = 'https://api.spotify.com/v1/playlists/{}'
-album_base_url = 'https://api.spotify.com/v1/albums/{}'
-track_base_url = 'https://api.spotify.com/v1/tracks/{}'
-artist_base_url = 'https://api.spotify.com/v1/artists/{}'
-artist_albums_url = 'https://api.spotify.com/v1/artists/{}/albums'
-headers = {
- 'User-Agent': get_random_user_agent(),
- 'Accept': 'application/json',
- 'Accept-Language': 'en-US,en;q=0.9',
- 'Accept-Encoding': 'gzip, deflate, br',
- 'sec-ch-ua-platform': '"Windows"',
- 'sec-fetch-dest': 'empty',
- 'sec-fetch-mode': 'cors',
- 'sec-fetch-site': 'same-origin',
- 'Referer': 'https://open.spotify.com/',
- 'Origin': 'https://open.spotify.com'
-}
-
-class SpotifyInvalidUrlException(Exception):
- pass
-
-class SpotifyWebsiteParserException(Exception):
- pass
-
-def parse_uri(uri):
- u = urlparse(uri)
- if u.netloc == "embed.spotify.com":
- if not u.query:
- raise SpotifyInvalidUrlException("ERROR: url {} is not supported".format(uri))
- qs = parse_qs(u.query)
- return parse_uri(qs['uri'][0])
-
- if not u.scheme and not u.netloc:
- return {"type": "playlist", "id": u.path}
-
- if u.scheme == "spotify":
- parts = uri.split(":")
- else:
- if u.netloc != "open.spotify.com" and u.netloc != "play.spotify.com":
- raise SpotifyInvalidUrlException("ERROR: url {} is not supported".format(uri))
- parts = u.path.split("/")
-
- if parts[1] == "embed":
- parts = parts[1:]
-
- if len(parts) > 1 and parts[1].startswith("intl-"):
- parts = parts[1:]
-
- l = len(parts)
- if l == 3 and parts[1] in ["album", "track", "playlist", "artist"]:
- return {"type": parts[1], "id": parts[2]}
- if l == 5 and parts[3] == "playlist":
- return {"type": parts[3], "id": parts[4]}
- if l >= 4 and parts[1] == "artist" and len(parts) >= 4:
- if parts[3] == "discography":
- discography_type = "all"
- if len(parts) >= 5 and parts[4] in ["all", "album", "single", "compilation"]:
- discography_type = parts[4]
- return {"type": "artist_discography", "id": parts[2], "discography_type": discography_type}
- else:
- return {"type": "artist", "id": parts[2]}
-
- raise SpotifyInvalidUrlException("ERROR: unable to determine Spotify URL type or type is unsupported.")
-
-def get_json_from_api(api_url, access_token):
- headers.update({'Authorization': 'Bearer {}'.format(access_token)})
-
- req = requests.get(api_url, headers=headers, timeout=10)
-
- if req.status_code == 429:
- seconds = int(req.headers.get("Retry-After", "5")) + 1
- print(f"INFO: rate limited! Sleeping for {seconds} seconds")
- sleep(seconds)
- return None
-
- if req.status_code != 200:
- raise SpotifyWebsiteParserException(f"ERROR: {api_url} gave us not a 200. Instead: {req.status_code}")
-
- return req.json()
-
-def get_access_token():
- try:
- totp, server_time, totp_version = generate_totp()
- otp_code = totp.at(int(server_time))
- timestamp_ms = int(time.time() * 1000)
-
- params = {
- 'reason': 'init',
- 'productType': 'web-player',
- 'totp': otp_code,
- 'totpServerTime': server_time,
- 'totpVer': str(totp_version),
- 'sTime': server_time,
- 'cTime': timestamp_ms,
- 'buildVer': 'web-player_2025-07-02_1720000000000_12345678',
- 'buildDate': '2025-07-02'
- }
-
- req = requests.get(token_url, headers=headers, params=params, timeout=10)
- if req.status_code != 200:
- return {"error": f"Failed to get access token. Status code: {req.status_code}"}
- return req.json()
- except Exception as e:
- return {"error": f"Failed to get access token: {str(e)}"}
-
-def fetch_tracks_in_batches(url: str, access_token: str, batch_size: int = 100, delay: float = 1.0) -> Tuple[List[Dict[str, Any]], int]:
- all_tracks = []
- current_batch = 0
-
- while url:
- print(f"Batch : {current_batch}")
-
- url_parts = url.split("offset=")
- if len(url_parts) > 1:
- offset_part = url_parts[1].split("&")[0]
- print(f"Offset : {offset_part}")
- print("-------------")
-
- track_data = get_json_from_api(url, access_token)
- if not track_data:
- break
-
- items = track_data.get('items', [])
- all_tracks.extend(items)
-
- url = track_data.get('next')
- if url and "&locale=" in url:
- url = url.split("&locale=")[0]
-
- if url and delay > 0:
- sleep(delay)
-
- current_batch += 1
-
- return all_tracks, current_batch
-
-def get_raw_spotify_data(spotify_url, batch: bool = False, delay: float = 1.0):
- url_info = parse_uri(spotify_url)
- token = get_access_token()
-
- if "error" in token:
- return token
-
- access_token = token["accessToken"]
- raw_data = {}
-
- if url_info['type'] == "playlist":
- try:
- playlist_data = get_json_from_api(
- playlist_base_url.format(url_info["id"]),
- access_token
- )
- if not playlist_data:
- return {"error": "Failed to get playlist data"}
-
- raw_data = playlist_data
- total_tracks = playlist_data.get('tracks', {}).get('total', 0)
-
- if batch:
- tracks_url = f'https://api.spotify.com/v1/playlists/{url_info["id"]}/tracks?limit=100'
- tracks, num_batches = fetch_tracks_in_batches(tracks_url, access_token, 100, delay)
- raw_data['tracks']['items'] = tracks
- raw_data['_batch_count'] = num_batches
- raw_data['_batch_enabled'] = True
-
- if len(tracks) < total_tracks:
- last_offset = len(tracks)
- remaining_tracks = []
-
- while last_offset < total_tracks:
- print(f"Batch : {num_batches}")
- print(f"Offset : {last_offset}")
- print("-------------")
-
- remainder_url = f'https://api.spotify.com/v1/playlists/{url_info["id"]}/tracks?offset={last_offset}&limit=100'
- track_data = get_json_from_api(remainder_url, access_token)
-
- if not track_data or not track_data.get('items'):
- break
-
- items = track_data.get('items', [])
- remaining_tracks.extend(items)
-
- if len(items) < 100:
- break
-
- last_offset += len(items)
- num_batches += 1
-
- if delay > 0:
- sleep(delay)
-
- tracks.extend(remaining_tracks)
- raw_data['tracks']['items'] = tracks
- raw_data['_batch_count'] = num_batches
- else:
- tracks = []
- tracks_url = f'https://api.spotify.com/v1/playlists/{url_info["id"]}/tracks?limit=100'
- while tracks_url:
- track_data = get_json_from_api(tracks_url, access_token)
- if not track_data:
- break
-
- tracks.extend(track_data['items'])
- tracks_url = track_data.get('next')
- if tracks_url and "&locale=" in tracks_url:
- tracks_url = tracks_url.split("&locale=")[0]
-
- raw_data['tracks']['items'] = tracks
- raw_data['_batch_enabled'] = False
-
- except Exception as e:
- return {"error": f"Failed to get playlist data: {str(e)}"}
-
- elif url_info["type"] == "album":
- try:
- album_data = get_json_from_api(
- album_base_url.format(url_info["id"]),
- access_token
- )
- if not album_data:
- return {"error": "Failed to get album data"}
-
- album_data['_token'] = access_token
- raw_data = album_data
- total_tracks = album_data.get('total_tracks', 0)
-
- if batch:
- tracks_url = f'{album_base_url.format(url_info["id"])}/tracks?limit=50'
- tracks, num_batches = fetch_tracks_in_batches(tracks_url, access_token, 50, delay)
- raw_data['tracks']['items'] = tracks
- raw_data['_batch_count'] = num_batches
- raw_data['_batch_enabled'] = True
-
- if len(tracks) < total_tracks:
- last_offset = len(tracks)
- remaining_tracks = []
-
- while last_offset < total_tracks:
- print(f"Batch : {num_batches}")
- print(f"Offset : {last_offset}")
- print("-------------")
-
- remainder_url = f'{album_base_url.format(url_info["id"])}/tracks?offset={last_offset}&limit=50'
- track_data = get_json_from_api(remainder_url, access_token)
-
- if not track_data or not track_data.get('items'):
- break
-
- items = track_data.get('items', [])
- remaining_tracks.extend(items)
-
- if len(items) < 50:
- break
-
- last_offset += len(items)
- num_batches += 1
-
- if delay > 0:
- sleep(delay)
-
- tracks.extend(remaining_tracks)
- raw_data['tracks']['items'] = tracks
- raw_data['_batch_count'] = num_batches
- else:
- tracks = []
- tracks_url = f'{album_base_url.format(url_info["id"])}/tracks?limit=50'
- while tracks_url:
- track_data = get_json_from_api(tracks_url, access_token)
- if not track_data:
- break
-
- tracks.extend(track_data['items'])
- tracks_url = track_data.get('next')
- if tracks_url and "&locale=" in tracks_url:
- tracks_url = tracks_url.split("&locale=")[0]
-
- raw_data['tracks']['items'] = tracks
- raw_data['_batch_enabled'] = False
-
- except Exception as e:
- return {"error": f"Failed to get album data: {str(e)}"}
-
- elif url_info["type"] == "track":
- try:
- track_data = get_json_from_api(
- track_base_url.format(url_info["id"]),
- access_token
- )
- if not track_data:
- return {"error": "Failed to get track data"}
-
- raw_data = track_data
- except Exception as e:
- return {"error": f"Failed to get track data: {str(e)}"}
-
- elif url_info["type"] == "artist_discography":
- try:
- artist_data = get_json_from_api(
- artist_base_url.format(url_info["id"]),
- access_token
- )
- if not artist_data:
- return {"error": "Failed to get artist data"}
-
- discography_type = url_info.get("discography_type", "all")
- if discography_type == "all":
- include_groups = "album,single,compilation"
- else:
- include_groups = discography_type
-
- albums = []
- albums_url = f'{artist_albums_url.format(url_info["id"])}?include_groups={include_groups}&limit=50'
-
- if batch:
- albums, num_batches = fetch_tracks_in_batches(albums_url, access_token, 50, delay)
- raw_data = {
- "artist_info": artist_data,
- "albums": albums,
- "discography_type": discography_type,
- "_batch_count": num_batches,
- "_batch_enabled": True
- }
- else:
- while albums_url:
- album_data = get_json_from_api(albums_url, access_token)
- if not album_data:
- break
-
- albums.extend(album_data['items'])
- albums_url = album_data.get('next')
- if albums_url and "&locale=" in albums_url:
- albums_url = albums_url.split("&locale=")[0]
-
- raw_data = {
- "artist_info": artist_data,
- "albums": albums,
- "discography_type": discography_type,
- "_batch_enabled": False
- }
-
- raw_data['_token'] = access_token
-
- except Exception as e:
- return {"error": f"Failed to get artist discography data: {str(e)}"}
-
- elif url_info["type"] == "artist":
- try:
- artist_data = get_json_from_api(
- artist_base_url.format(url_info["id"]),
- access_token
- )
- if not artist_data:
- return {"error": "Failed to get artist data"}
-
- raw_data = artist_data
- except Exception as e:
- return {"error": f"Failed to get artist data: {str(e)}"}
-
- return raw_data
-
-def format_track_data(track_data):
- artists = []
- for artist in track_data.get('artists', []):
- artists.append(artist['name'])
-
- image_url = track_data.get('album', {}).get('images', [{}])[0].get('url', '') if track_data.get('album', {}).get('images') else ''
-
- isrc = track_data.get('external_ids', {}).get('isrc', '')
-
- return {
- "track": {
- "artists": ", ".join(artists),
- "name": track_data.get('name', ''),
- "album_name": track_data.get('album', {}).get('name', ''),
- "duration_ms": track_data.get('duration_ms', 0),
- "images": image_url,
- "release_date": track_data.get('album', {}).get('release_date', ''),
- "track_number": track_data.get('track_number', 0),
- "external_urls": track_data.get('external_urls', {}).get('spotify', ''),
- "isrc": isrc
- }
- }
-
-def format_album_data(album_data):
- artists = []
- for artist in album_data.get('artists', []):
- artists.append(artist['name'])
-
- image_url = album_data.get('images', [{}])[0].get('url', '') if album_data.get('images') else ''
-
- track_list = []
- for track in album_data.get('tracks', {}).get('items', []):
- track_artists = []
- for artist in track.get('artists', []):
- track_artists.append(artist['name'])
-
- track_id = track.get('id', '')
- track_isrc = ''
-
- if track_id and album_data.get('_token'):
- try:
- full_track_data = get_json_from_api(
- track_base_url.format(track_id),
- album_data.get('_token')
- )
- if full_track_data:
- track_isrc = full_track_data.get('external_ids', {}).get('isrc', '')
- except:
- pass
-
- track_list.append({
- "artists": ", ".join(track_artists),
- "name": track.get('name', ''),
- "album_name": album_data.get('name', ''),
- "duration_ms": track.get('duration_ms', 0),
- "images": image_url,
- "release_date": album_data.get('release_date', ''),
- "track_number": track.get('track_number', 0),
- "external_urls": track.get('external_urls', {}).get('spotify', ''),
- "isrc": track_isrc
- })
-
- album_info = {
- "total_tracks": album_data.get('total_tracks', 0),
- "name": album_data.get('name', ''),
- "release_date": album_data.get('release_date', ''),
- "artists": ", ".join(artists),
- "images": image_url
- }
-
- if album_data.get('_batch_enabled', False):
- album_info["batch"] = f"{album_data.get('_batch_count', 1)}"
-
- return {
- "album_info": album_info,
- "track_list": track_list
- }
-
-def format_playlist_data(playlist_data):
- image_url = playlist_data.get('images', [{}])[0].get('url', '') if playlist_data.get('images') else ''
-
- track_list = []
- for item in playlist_data.get('tracks', {}).get('items', []):
- track = item.get('track', {})
- if not track:
- continue
-
- artists = []
- for artist in track.get('artists', []):
- artists.append(artist['name'])
-
- track_image = ''
- if track.get('album', {}).get('images'):
- track_image = track.get('album', {}).get('images', [{}])[0].get('url', '')
-
- track_isrc = track.get('external_ids', {}).get('isrc', '')
-
- track_list.append({
- "artists": ", ".join(artists),
- "name": track.get('name', ''),
- "album_name": track.get('album', {}).get('name', ''),
- "duration_ms": track.get('duration_ms', 0),
- "images": track_image,
- "release_date": track.get('album', {}).get('release_date', ''),
- "track_number": track.get('track_number', 0),
- "external_urls": track.get('external_urls', {}).get('spotify', ''),
- "isrc": track_isrc
- })
-
- playlist_info = {
- "tracks": {"total": playlist_data.get('tracks', {}).get('total', 0)},
- "followers": {"total": playlist_data.get('followers', {}).get('total', 0)},
- "owner": {
- "display_name": playlist_data.get('owner', {}).get('display_name', ''),
- "name": playlist_data.get('name', ''),
- "images": image_url
- }
- }
-
- if playlist_data.get('_batch_enabled', False):
- playlist_info["batch"] = f"{playlist_data.get('_batch_count', 1)}"
-
- return {
- "playlist_info": playlist_info,
- "track_list": track_list
- }
-
-def format_artist_discography_data(discography_data):
- artist_info = discography_data.get('artist_info', {})
- albums = discography_data.get('albums', [])
- access_token = discography_data.get('_token', '')
-
- artist_image = ''
- if artist_info.get('images'):
- artist_image = artist_info.get('images', [{}])[0].get('url', '')
-
- formatted_artist_info = {
- "name": artist_info.get('name', ''),
- "followers": artist_info.get('followers', {}).get('total', 0),
- "genres": artist_info.get('genres', []),
- "images": artist_image,
- "external_urls": artist_info.get('external_urls', {}).get('spotify', ''),
- "discography_type": discography_data.get('discography_type', 'all'),
- "total_albums": len(albums)
- }
-
- if discography_data.get('_batch_enabled', False):
- formatted_artist_info["batch"] = f"{discography_data.get('_batch_count', 1)}"
-
- album_list = []
- all_tracks = []
-
- for album in albums:
- album_image = ''
- if album.get('images'):
- album_image = album.get('images', [{}])[0].get('url', '')
-
- album_artists = []
- for artist in album.get('artists', []):
- album_artists.append(artist['name'])
-
- album_info = {
- "id": album.get('id', ''),
- "name": album.get('name', ''),
- "album_type": album.get('album_type', ''),
- "release_date": album.get('release_date', ''),
- "total_tracks": album.get('total_tracks', 0),
- "artists": ", ".join(album_artists),
- "images": album_image,
- "external_urls": album.get('external_urls', {}).get('spotify', '')
- }
-
- album_list.append(album_info)
-
- if access_token and album.get('id'):
- try:
- album_tracks_data = get_json_from_api(
- f'{album_base_url.format(album.get("id"))}/tracks?limit=50',
- access_token
- )
-
- if album_tracks_data:
- tracks = []
- tracks_url = f'{album_base_url.format(album.get("id"))}/tracks?limit=50'
-
- while tracks_url:
- track_data = get_json_from_api(tracks_url, access_token)
- if not track_data:
- break
-
- tracks.extend(track_data['items'])
- tracks_url = track_data.get('next')
- if tracks_url and "&locale=" in tracks_url:
- tracks_url = tracks_url.split("&locale=")[0]
-
- for track in tracks:
- track_artists = []
- for artist in track.get('artists', []):
- track_artists.append(artist['name'])
-
- track_id = track.get('id', '')
- track_isrc = ''
-
- if track_id:
- try:
- full_track_data = get_json_from_api(
- track_base_url.format(track_id),
- access_token
- )
- if full_track_data:
- track_isrc = full_track_data.get('external_ids', {}).get('isrc', '')
- except:
- pass
-
- formatted_track = {
- "artists": ", ".join(track_artists),
- "name": track.get('name', ''),
- "album_name": album.get('name', ''),
- "album_type": album.get('album_type', ''),
- "duration_ms": track.get('duration_ms', 0),
- "images": album_image,
- "release_date": album.get('release_date', ''),
- "track_number": track.get('track_number', 0),
- "external_urls": track.get('external_urls', {}).get('spotify', ''),
- "isrc": track_isrc
- }
-
- all_tracks.append(formatted_track)
-
- except Exception as e:
- print(f"Error getting tracks for album {album.get('name', '')}: {str(e)}")
- continue
-
- return {
- "artist_info": formatted_artist_info,
- "album_list": album_list,
- "track_list": all_tracks
- }
-
-def format_artist_data(artist_data):
- artist_image = ''
- if artist_data.get('images'):
- artist_image = artist_data.get('images', [{}])[0].get('url', '')
-
- return {
- "artist": {
- "name": artist_data.get('name', ''),
- "followers": artist_data.get('followers', {}).get('total', 0),
- "genres": artist_data.get('genres', []),
- "images": artist_image,
- "external_urls": artist_data.get('external_urls', {}).get('spotify', ''),
- "popularity": artist_data.get('popularity', 0)
- }
- }
-
-def process_spotify_data(raw_data, data_type):
- if not raw_data or "error" in raw_data:
- return {"error": "Invalid data provided"}
-
- try:
- if data_type == "track":
- return format_track_data(raw_data)
- elif data_type == "album":
- return format_album_data(raw_data)
- elif data_type == "playlist":
- return format_playlist_data(raw_data)
- elif data_type == "artist_discography":
- return format_artist_discography_data(raw_data)
- elif data_type == "artist":
- return format_artist_data(raw_data)
- else:
- return {"error": "Invalid data type"}
- except Exception as e:
- return {"error": f"Error processing data: {str(e)}"}
-
-def get_filtered_data(spotify_url, batch=False, delay=1.0):
- raw_data = get_raw_spotify_data(spotify_url, batch=batch, delay=delay)
- if raw_data and "error" not in raw_data:
- url_info = parse_uri(spotify_url)
- filtered_data = process_spotify_data(raw_data, url_info['type'])
- return filtered_data
- return {"error": "Failed to get raw data"}
-
-if __name__ == '__main__':
- playlist = "https://open.spotify.com/playlist/37i9dQZEVXbNG2KDcFcKOF"
- album = "https://open.spotify.com/album/6J84szYCnMfzEcvIcfWMFL"
- song = "https://open.spotify.com/track/7so0lgd0zP2Sbgs2d7a1SZ"
-
- artist_discography_all = "https://open.spotify.com/artist/0du5cEVh5yTK9QJze8zA0C/discography/all"
- artist_discography_albums = "https://open.spotify.com/artist/0du5cEVh5yTK9QJze8zA0C/discography/album"
- artist_discography_singles = "https://open.spotify.com/artist/0du5cEVh5yTK9QJze8zA0C/discography/single"
- artist_discography_compilations = "https://open.spotify.com/artist/0du5cEVh5yTK9QJze8zA0C/discography/compilation"
-
- print("=== Testing Artist Discography (All) ===")
- filtered_discography = get_filtered_data(artist_discography_all, batch=True, delay=0.1)
- print(json.dumps(filtered_discography, indent=2))
-
- print("\n=== Testing Playlist ===")
- filtered_playlist = get_filtered_data(playlist, batch=True, delay=0.1)
- print(json.dumps(filtered_playlist, indent=2))
-
- print("\n=== Testing Album ===")
- filtered_album = get_filtered_data(album)
- print(json.dumps(filtered_album, indent=2))
-
- print("\n=== Testing Track ===")
- filtered_track = get_filtered_data(song)
- print(json.dumps(filtered_track, indent=2))
\ No newline at end of file
diff --git a/getSecret.py b/getSecret.py
deleted file mode 100644
index 5b6354c..0000000
--- a/getSecret.py
+++ /dev/null
@@ -1,95 +0,0 @@
-import json
-import time
-from pathlib import Path
-from DrissionPage import ChromiumPage, ChromiumOptions
-
-def summarise(caps):
- real = {}
- for cap in caps:
- sec = cap.get("secret")
- if not sec or not isinstance(sec, str):
- continue
- ver = cap.get("version") or cap.get("obj", {}).get("version")
- if ver and ver != 0:
- real[str(int(ver))] = sec
-
- if not real:
- return False, "No secrets found."
-
- versions = sorted(int(k) for k in real.keys())
- secret_bytes = [
- {"version": v, "secret": [ord(c) for c in real[str(v)]]}
- for v in versions
- ]
-
- secrets_dir = Path.home() / ".spotify-secret"
- secrets_dir.mkdir(exist_ok=True)
-
- output_file = secrets_dir / "secretBytes.json"
- with open(output_file, "w") as f:
- json.dump(secret_bytes, f, indent=2)
-
- return True, f"Saved to: {output_file}"
-
-def grab_live(progress_callback=None):
- def emit_progress(msg):
- if progress_callback:
- progress_callback(msg)
- else:
- print(msg)
-
- stealth = """(()=>{
- Object.defineProperty(navigator,'webdriver',{get:()=>false});
- Object.defineProperty(navigator,'languages',{get:()=>['en-US','en']});
- Object.defineProperty(navigator,'plugins',{get:()=>[1,2,3,4,5]});
- window.chrome={runtime:{}};
- const q=navigator.permissions.query;
- navigator.permissions.query=p=>p.name==='notifications'?Promise.resolve({state:Notification.permission}):q(p);
- const g=WebGLRenderingContext.prototype.getParameter;
- WebGLRenderingContext.prototype.getParameter=function(p){
- if(p===37445)return'Intel Inc.';if(p===37446)return'Intel Iris OpenGL Engine';return g.call(this,p);
- };
- })();"""
-
- hook = """(()=>{if(globalThis.__secretHookInstalled)return;
- globalThis.__secretHookInstalled=true;globalThis.__captures=[];
- Object.defineProperty(Object.prototype,'secret',{configurable:true,set:function(v){
- try{__captures.push({secret:v,version:this.version,obj:this});}catch(e){}
- Object.defineProperty(this,'secret',{value:v,writable:true,configurable:true,enumerable:true});}});
- })();"""
-
- co = ChromiumOptions()
- co.headless(True)
- co.set_argument('--disable-blink-features=AutomationControlled')
- co.set_argument('--no-sandbox')
-
- page = ChromiumPage(addr_or_opts=co)
- try:
- page.run_cdp('Page.addScriptToEvaluateOnNewDocument', source=stealth)
- page.run_cdp('Page.addScriptToEvaluateOnNewDocument', source=hook)
- emit_progress("Opening Spotify...")
- page.get("https://open.spotify.com")
- time.sleep(3)
- caps = page.run_js("return globalThis.__captures || []")
- for c in caps:
- if isinstance(c, dict) and c.get("secret") and c.get("version"):
- emit_progress(f"Secret({int(c['version'])}): {c['secret']}")
- return caps or []
- finally:
- page.quit()
-
-def scrape_and_save(progress_callback=None):
- try:
- caps = grab_live(progress_callback)
- return summarise(caps)
- except Exception as e:
- return False, f"Error: {str(e)}"
-
-def main():
- success, message = scrape_and_save()
- print(message)
- return 0 if success else 1
-
-if __name__ == "__main__":
- import sys
- sys.exit(main())
\ No newline at end of file
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..7c465eb
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,40 @@
+module spotiflac
+
+go 1.25.4
+
+require (
+ github.com/go-flac/flacpicture v0.3.0
+ github.com/go-flac/flacvorbis v0.2.0
+ github.com/go-flac/go-flac v1.0.0
+ github.com/wailsapp/wails/v2 v2.11.0
+)
+
+require (
+ github.com/bep/debounce v1.2.1 // indirect
+ github.com/go-ole/go-ole v1.3.0 // indirect
+ github.com/godbus/dbus/v5 v5.1.0 // indirect
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/gorilla/websocket v1.5.3 // indirect
+ github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
+ github.com/labstack/echo/v4 v4.13.3 // indirect
+ github.com/labstack/gommon v0.4.2 // indirect
+ github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
+ github.com/leaanthony/gosod v1.0.4 // indirect
+ github.com/leaanthony/slicer v1.6.0 // indirect
+ github.com/leaanthony/u v1.1.1 // indirect
+ github.com/mattn/go-colorable v0.1.13 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
+ github.com/pkg/errors v0.9.1 // indirect
+ github.com/rivo/uniseg v0.4.7 // indirect
+ github.com/samber/lo v1.49.1 // indirect
+ github.com/tkrajina/go-reflector v0.5.8 // indirect
+ github.com/valyala/bytebufferpool v1.0.0 // indirect
+ github.com/valyala/fasttemplate v1.2.2 // indirect
+ github.com/wailsapp/go-webview2 v1.0.22 // indirect
+ github.com/wailsapp/mimetype v1.4.1 // indirect
+ golang.org/x/crypto v0.33.0 // indirect
+ golang.org/x/net v0.35.0 // indirect
+ golang.org/x/sys v0.30.0 // indirect
+ golang.org/x/text v0.22.0 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..68bd037
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,87 @@
+github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
+github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/go-flac/flacpicture v0.3.0 h1:LkmTxzFLIynwfhHiZsX0s8xcr3/u33MzvV89u+zOT8I=
+github.com/go-flac/flacpicture v0.3.0/go.mod h1:DPbrzVYQ3fJcvSgLFp9HXIrEQEdfdk/+m0nQCzwodZI=
+github.com/go-flac/flacvorbis v0.2.0 h1:KH0xjpkNTXFER4cszH4zeJxYcrHbUobz/RticWGOESs=
+github.com/go-flac/flacvorbis v0.2.0/go.mod h1:uIysHOtuU7OLGoCRG92bvnkg7QEqHx19qKRV6K1pBrI=
+github.com/go-flac/go-flac v1.0.0 h1:6qI9XOVLcO50xpzm3nXvO31BgDgHhnr/p/rER/K/doY=
+github.com/go-flac/go-flac v1.0.0/go.mod h1:WnZhcpmq4u1UdZMNn9LYSoASpWOCMOoxXxcWEHSzkW8=
+github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
+github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
+github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
+github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
+github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
+github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
+github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
+github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
+github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
+github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
+github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
+github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc=
+github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA=
+github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
+github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
+github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI=
+github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw=
+github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js=
+github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
+github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
+github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
+github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
+github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
+github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
+github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
+github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
+github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
+github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
+github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
+github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
+github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
+github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
+github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
+github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
+github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
+github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
+github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
+github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
+github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
+github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
+github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58=
+github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
+github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
+github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
+github.com/wailsapp/wails/v2 v2.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSBtqQ=
+github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k=
+golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
+golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
+golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
+golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
+golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
+golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
+golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/icons/circle-x.svg b/icons/circle-x.svg
deleted file mode 100644
index 3625f95..0000000
--- a/icons/circle-x.svg
+++ /dev/null
@@ -1,5 +0,0 @@
-
diff --git a/icons/deezer.png b/icons/deezer.png
deleted file mode 100644
index db9cdd1..0000000
Binary files a/icons/deezer.png and /dev/null differ
diff --git a/icons/download.svg b/icons/download.svg
deleted file mode 100644
index c77f62c..0000000
--- a/icons/download.svg
+++ /dev/null
@@ -1,5 +0,0 @@
-
diff --git a/icons/icon.ico b/icons/icon.ico
deleted file mode 100644
index 8686c16..0000000
Binary files a/icons/icon.ico and /dev/null differ
diff --git a/icons/icon.svg b/icons/icon.svg
deleted file mode 100644
index 455ce78..0000000
--- a/icons/icon.svg
+++ /dev/null
@@ -1,48 +0,0 @@
-
-
-
diff --git a/icons/tidal.png b/icons/tidal.png
deleted file mode 100644
index 8d37f2b..0000000
Binary files a/icons/tidal.png and /dev/null differ
diff --git a/icons/tool.svg b/icons/tool.svg
deleted file mode 100644
index 29229f9..0000000
--- a/icons/tool.svg
+++ /dev/null
@@ -1,3 +0,0 @@
-
diff --git a/icons/trash.svg b/icons/trash.svg
deleted file mode 100644
index 20fb5cb..0000000
--- a/icons/trash.svg
+++ /dev/null
@@ -1,6 +0,0 @@
-
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..e2adf6a
--- /dev/null
+++ b/main.go
@@ -0,0 +1,43 @@
+package main
+
+import (
+ "embed"
+ "log"
+
+ "github.com/wailsapp/wails/v2"
+ "github.com/wailsapp/wails/v2/pkg/options"
+ "github.com/wailsapp/wails/v2/pkg/options/assetserver"
+ "github.com/wailsapp/wails/v2/pkg/options/windows"
+)
+
+//go:embed all:frontend/dist
+var assets embed.FS
+
+func main() {
+ // Create an instance of the app structure
+ app := NewApp()
+
+ // Create application with options
+ err := wails.Run(&options.App{
+ Title: "SpotiFLAC",
+ Width: 1280,
+ Height: 800,
+ AssetServer: &assetserver.Options{
+ Assets: assets,
+ },
+ BackgroundColour: &options.RGBA{R: 0, G: 0, B: 0, A: 255},
+ OnStartup: app.startup,
+ Bind: []interface{}{
+ app,
+ },
+ Windows: &windows.Options{
+ WebviewIsTransparent: false,
+ WindowIsTranslucent: false,
+ DisableWindowIcon: false,
+ },
+ })
+
+ if err != nil {
+ log.Fatal("Error:", err.Error())
+ }
+}
diff --git a/requirements.txt b/requirements.txt
deleted file mode 100644
index 4dcf0fc..0000000
--- a/requirements.txt
+++ /dev/null
@@ -1,9 +0,0 @@
-PyQt6
-pyqt6-tools
-pyqtdarktheme
-requests
-mutagen
-pyotp
-packaging
-pyinstaller
-DrissionPage
\ No newline at end of file
diff --git a/tidalDL.py b/tidalDL.py
deleted file mode 100644
index e53f501..0000000
--- a/tidalDL.py
+++ /dev/null
@@ -1,495 +0,0 @@
-import os
-import re
-import time
-import base64
-import requests
-import json
-from mutagen.flac import FLAC, Picture
-from mutagen.id3 import PictureType
-
-class ProgressCallback:
- def __call__(self, current, total):
- if total > 0:
- percent = (current / total) * 100
- print(f"\r{percent:.2f}% ({current}/{total})", end="")
- else:
- print(f"\r{current / (1024 * 1024):.2f} MB", end="")
-
-class TidalDownloader:
- def __init__(self, timeout=30, max_retries=3, api_url=None):
- self.timeout = timeout
- self.max_retries = max_retries
- self.download_chunk_size = 256 * 1024
- self.progress_callback = ProgressCallback()
- self.client_id = base64.b64decode("NkJEU1JkcEs5aHFFQlRnVQ==").decode()
- self.client_secret = base64.b64decode("eGV1UG1ZN25icFo5SUliTEFjUTkzc2hrYTFWTmhlVUFxTjZJY3N6alRHOD0=").decode()
- self.api_url = api_url
-
- @staticmethod
- def get_available_apis():
- try:
- response = requests.get("https://raw.githubusercontent.com/afkarxyz/SpotiFLAC/refs/heads/main/tidal.json", timeout=10)
-
- if response.status_code == 200:
- api_list = response.json()
-
- api_instances = [{"url": f"https://{api}"} for api in api_list]
-
- return api_instances
- else:
- print(f"Failed to fetch API list: HTTP {response.status_code}")
- return []
-
- except Exception as e:
- print(f"Failed to fetch API list: {e}")
- return []
-
- @staticmethod
- def select_api_interactive():
- apis = TidalDownloader.get_available_apis()
-
- if not apis:
- raise Exception("No APIs available. Cannot proceed.")
-
- print("\n=== Available API Instances ===")
- print(f"{'No':<4} {'URL':<50}")
- print("-" * 60)
-
- for i, api in enumerate(apis, 1):
- url = api.get('url', 'N/A')
- print(f"{i:<4} {url:<50}")
-
- print("-" * 60)
-
- while True:
- try:
- choice = input(f"\nSelect API (1-{len(apis)}) [1]: ").strip()
-
- if not choice:
- choice = "1"
-
- choice_num = int(choice)
-
- if 1 <= choice_num <= len(apis):
- selected_url = apis[choice_num - 1]['url']
- print(f"\nSelected: {selected_url}")
- return selected_url
- else:
- print(f"Invalid choice. Please enter 1-{len(apis)}")
- except ValueError:
- print("Invalid input. Please enter a number.")
- except KeyboardInterrupt:
- print("\nCancelled")
- raise Exception("API selection cancelled")
-
- def set_progress_callback(self, callback):
- self.progress_callback = callback
-
- def sanitize_filename(self, filename):
- if not filename:
- return "Unknown Track"
- sanitized = re.sub(r'[\\/*?:"<>|]', "", str(filename))
- return re.sub(r'\s+', ' ', sanitized).strip() or "Unnamed Track"
-
- def get_access_token(self):
- refresh_url = "https://auth.tidal.com/v1/oauth2/token"
-
- payload = {
- "client_id": self.client_id,
- "grant_type": "client_credentials",
- }
-
- try:
- response = requests.post(
- url=refresh_url,
- data=payload,
- auth=(self.client_id, self.client_secret),
- timeout=self.timeout
- )
-
- if response.status_code == 200:
- token_data = response.json()
- return token_data.get("access_token")
- else:
- return None
-
- except:
- return None
-
- def search_tracks(self, query):
- try:
- tidal_token = self.get_access_token()
- if not tidal_token:
- raise Exception("Failed to get access token")
-
- search_url = f"https://api.tidal.com/v1/search/tracks?query={query}&limit=25&offset=0&countryCode=US"
- header = {"authorization": f"Bearer {tidal_token}"}
-
- search_data = requests.get(url=search_url, headers=header, timeout=self.timeout)
- response_data = search_data.json()
-
- filtered_items = [{
- "id": item.get("id"),
- "title": item.get("title"),
- "url": item.get("url"),
- "isrc": item.get("isrc"),
- "audioQuality": item.get("audioQuality"),
- "mediaMetadata": item.get("mediaMetadata"),
- "album": item.get("album", {}),
- "artists": item.get("artists", []),
- "artist": item.get("artist", {}),
- "trackNumber": item.get("trackNumber"),
- "volumeNumber": item.get("volumeNumber"),
- "duration": item.get("duration"),
- "copyright": item.get("copyright"),
- "explicit": item.get("explicit")
- } for item in response_data.get("items", [])]
-
- return {
- "limit": response_data.get("limit"),
- "offset": response_data.get("offset"),
- "totalNumberOfItems": response_data.get("totalNumberOfItems"),
- "items": filtered_items
- }
-
- except Exception as e:
- raise Exception(f"Search error: {str(e)}")
-
- def get_track_info(self, query, isrc=None):
- print(f"Fetching: {query}" + (f" (ISRC: {isrc})" if isrc else ""))
-
- try:
- result = self.search_tracks(query)
-
- if not result or not result.get("items"):
- raise Exception(f"No tracks found for query: {query}")
-
- selected_track = None
- if isrc:
- isrc_items = [item for item in result["items"] if item.get("isrc") == isrc]
-
- if len(isrc_items) > 1:
- hires_items = []
- for item in isrc_items:
- media_metadata = item.get("mediaMetadata", {})
- tags = media_metadata.get("tags", []) if media_metadata else []
- if "HIRES_LOSSLESS" in tags:
- hires_items.append(item)
-
- if hires_items:
- selected_track = hires_items[0]
- else:
- selected_track = isrc_items[0]
- elif len(isrc_items) == 1:
- selected_track = isrc_items[0]
- else:
- selected_track = result["items"][0]
- else:
- selected_track = result["items"][0]
-
- if not selected_track:
- raise Exception(f"Track not found: {query}" + (f" (ISRC: {isrc})" if isrc else ""))
-
- title = selected_track.get('title', 'Unknown')
- quality = selected_track.get('audioQuality', 'Unknown')
- print(f"Found: {title} ({quality})")
- return selected_track
-
- except Exception as e:
- raise Exception(f"Error getting track info: {str(e)}")
-
- def get_download_url(self, track_id, quality="LOSSLESS"):
- print("Fetching URL...")
- download_api_url = f"{self.api_url}/track/?id={track_id}&quality={quality}"
-
- try:
- response = requests.get(download_api_url, timeout=self.timeout)
-
- if response.status_code == 200:
- data = response.json()
-
- for item in data:
- if "OriginalTrackUrl" in item:
- print("URL found")
- return {
- "download_url": item["OriginalTrackUrl"],
- "track_info": data[0] if data else {}
- }
-
- raise Exception("Download URL not found in response")
- else:
- raise Exception(f"API returned status code: {response.status_code}")
-
- except Exception as e:
- raise Exception(f"Error getting download URL: {str(e)}")
-
- def download_album_art(self, album_id, size="1280x1280"):
- try:
- art_url = f"https://resources.tidal.com/images/{album_id.replace('-', '/')}/{size}.jpg"
-
- response = requests.get(art_url, timeout=self.timeout)
-
- if response.status_code == 200:
- return response.content
- else:
- print(f"Failed to download album art: HTTP {response.status_code}")
- return None
-
- except Exception as e:
- print(f"Error downloading album art: {str(e)}")
- return None
-
- def download_file(self, url, filepath, is_paused_callback=None, is_stopped_callback=None):
- file_dir = os.path.dirname(filepath)
- if file_dir and not os.path.exists(file_dir):
- os.makedirs(file_dir, exist_ok=True)
-
- temp_filepath = filepath + ".part"
- retry_count = 0
-
- while retry_count <= self.max_retries:
- try:
- response = requests.get(url, timeout=60.0)
- if response.status_code != 200:
- raise Exception(f"HTTP {response.status_code}")
-
- if is_stopped_callback and is_stopped_callback():
- raise Exception("Download stopped")
-
- while is_paused_callback and is_paused_callback():
- time.sleep(0.1)
- if is_stopped_callback and is_stopped_callback():
- raise Exception("Download stopped")
-
- with open(temp_filepath, 'wb') as f:
- f.write(response.content)
-
- downloaded_size = len(response.content)
-
- if self.progress_callback:
- self.progress_callback(downloaded_size, downloaded_size)
-
- os.rename(temp_filepath, filepath)
- print("Download complete")
- return {"success": True, "size": downloaded_size}
-
- except Exception as e:
- retry_count += 1
- if retry_count > self.max_retries:
- if os.path.exists(temp_filepath):
- try:
- os.remove(temp_filepath)
- except:
- pass
- raise Exception(f"Download error after {self.max_retries} retries: {str(e)}")
-
- print(f"Download error (attempt {retry_count}/{self.max_retries}): {str(e)}")
- print(f"Retrying in {retry_count * 2} seconds...")
- time.sleep(retry_count * 2)
-
- def embed_metadata(self, filepath, track_info, search_info=None):
- try:
- print("Embedding metadata...")
- audio = FLAC(filepath)
- audio.clear()
- audio.clear_pictures()
-
- if track_info.get("title"):
- audio["TITLE"] = track_info["title"]
-
- artists_list = []
- if search_info and search_info.get("artists"):
- for artist in search_info["artists"]:
- if artist.get("name"):
- artists_list.append(artist["name"])
- elif search_info and search_info.get("artist") and search_info["artist"].get("name"):
- artists_list.append(search_info["artist"]["name"])
- elif track_info.get("artists"):
- for artist in track_info["artists"]:
- if artist.get("name"):
- artists_list.append(artist["name"])
- elif track_info.get("artist") and track_info["artist"].get("name"):
- artists_list.append(track_info["artist"]["name"])
-
- if artists_list:
- audio["ARTIST"] = artists_list[0]
- if len(artists_list) > 1:
- audio["ALBUMARTIST"] = "; ".join(artists_list)
- else:
- audio["ALBUMARTIST"] = artists_list[0]
-
- album_info = search_info.get("album", {}) if search_info else track_info.get("album", {})
- if album_info.get("title"):
- audio["ALBUM"] = album_info["title"]
-
- if search_info and search_info.get("trackNumber"):
- audio["TRACKNUMBER"] = str(search_info["trackNumber"])
- elif track_info.get("trackNumber"):
- audio["TRACKNUMBER"] = str(track_info["trackNumber"])
-
- if search_info and search_info.get("volumeNumber"):
- audio["DISCNUMBER"] = str(search_info["volumeNumber"])
- elif track_info.get("volumeNumber"):
- audio["DISCNUMBER"] = str(track_info["volumeNumber"])
-
- duration = search_info.get("duration") if search_info else track_info.get("duration")
- if duration:
- audio["LENGTH"] = str(duration)
-
- isrc = search_info.get("isrc") if search_info else track_info.get("isrc")
- if isrc:
- audio["ISRC"] = isrc
-
- copyright_info = search_info.get("copyright") if search_info else track_info.get("copyright")
- if copyright_info:
- audio["COPYRIGHT"] = copyright_info
-
- if album_info.get("releaseDate"):
- audio["DATE"] = album_info["releaseDate"][:4]
- try:
- audio["YEAR"] = album_info["releaseDate"][:4]
- except:
- pass
-
- if track_info.get("genre"):
- audio["GENRE"] = track_info["genre"]
-
- if track_info.get("audioQuality"):
- audio["COMMENT"] = f"Tidal {track_info['audioQuality']}"
-
- if album_info.get("cover"):
- album_art = self.download_album_art(album_info["cover"])
- if album_art:
- picture = Picture()
- picture.data = album_art
- picture.type = PictureType.COVER_FRONT
- picture.mime = "image/jpeg"
- picture.desc = "Cover"
- audio.add_picture(picture)
- print("Album art embedded")
-
- audio.save()
- print(f"Metadata embedded successfully for: {track_info.get('title', 'Unknown')}")
- return True
-
- except Exception as e:
- print(f"Error embedding metadata: {str(e)}")
- return False
-
- def download(self, query, isrc=None, output_dir=".", quality="LOSSLESS", is_paused_callback=None, is_stopped_callback=None, auto_fallback=False):
- if output_dir != ".":
- try:
- os.makedirs(output_dir, exist_ok=True)
- except OSError as e:
- raise Exception(f"Directory error: {e}")
-
- if auto_fallback:
- apis = self.get_available_apis()
- if not apis:
- raise Exception("No APIs available for fallback")
-
- last_error = None
- for i, api in enumerate(apis, 1):
- api_url = api.get('url')
- try:
- print(f"[Auto Fallback {i}/{len(apis)}] Trying: {api_url}")
-
- fallback_downloader = TidalDownloader(api_url=api_url)
- fallback_downloader.set_progress_callback(self.progress_callback)
-
- result = fallback_downloader._download_single(
- query, isrc, output_dir, quality,
- is_paused_callback, is_stopped_callback
- )
-
- print(f"✓ Success with: {api_url}")
- return result
-
- except Exception as e:
- last_error = str(e)
- print(f"✗ Failed with {api_url}: {last_error[:80]}")
- continue
-
- raise Exception(f"All {len(apis)} APIs failed. Last error: {last_error}")
-
- return self._download_single(query, isrc, output_dir, quality, is_paused_callback, is_stopped_callback)
-
- def _download_single(self, query, isrc, output_dir, quality, is_paused_callback, is_stopped_callback):
- track_info = self.get_track_info(query, isrc)
- track_id = track_info.get("id")
-
- if not track_id:
- raise Exception("No track ID found")
-
- artists_list = []
- if track_info.get("artists"):
- for artist in track_info["artists"]:
- if artist.get("name"):
- artists_list.append(artist["name"])
- elif track_info.get("artist") and track_info["artist"].get("name"):
- artists_list.append(track_info["artist"]["name"])
-
- artist_name = ", ".join(artists_list) if artists_list else "Unknown Artist"
- artist_name = self.sanitize_filename(artist_name)
- track_title = self.sanitize_filename(track_info.get("title", f"track_{track_id}"))
-
- output_filename = os.path.join(output_dir, f"{artist_name} - {track_title}.flac")
-
- if os.path.exists(output_filename):
- file_size = os.path.getsize(output_filename)
- if file_size > 0:
- print(f"File already exists: {output_filename} ({file_size / (1024 * 1024):.2f} MB)")
- return output_filename
-
- download_info = self.get_download_url(track_id, quality)
- download_url = download_info["download_url"]
- download_track_info = download_info["track_info"]
-
- print(f"Downloading to: {output_filename}")
- self.download_file(
- download_url,
- output_filename,
- is_paused_callback=is_paused_callback,
- is_stopped_callback=is_stopped_callback
- )
-
- print("Adding metadata...")
- try:
- self.embed_metadata(output_filename, download_track_info, track_info)
- print("Metadata saved")
- except Exception as e:
- print(f"Tagging failed: {e}")
-
- print("Done")
- return output_filename
-
-def main():
- print("=== TidalDL - Tidal Downloader ===")
-
- selected_api = TidalDownloader.select_api_interactive()
- downloader = TidalDownloader(timeout=30, max_retries=3, api_url=selected_api)
-
- query = "APT."
- isrc = "USAT22409172"
- output_dir = "."
-
- try:
- downloaded_file = downloader.download(query, isrc, output_dir)
- print(f"Success: File saved as {downloaded_file}")
- except Exception as e:
- print(f"Error: {str(e)}")
-
-if __name__ == "__main__":
- try:
- import sys
- if sys.platform == "win32":
- import os
- os.system("chcp 65001 > nul")
- try:
- sys.stdout.reconfigure(encoding='utf-8')
- except:
- pass
- except:
- pass
-
- main()
\ No newline at end of file
diff --git a/wails.json b/wails.json
new file mode 100644
index 0000000..f16dc65
--- /dev/null
+++ b/wails.json
@@ -0,0 +1,23 @@
+{
+ "$schema": "https://wails.io/schemas/config.v2.json",
+ "name": "SpotiFLAC",
+ "outputfilename": "SpotiFLAC",
+ "frontend:install": "pnpm install && pnpm run generate-icon",
+ "frontend:build": "pnpm run build",
+ "frontend:dev:watcher": "pnpm run dev",
+ "frontend:dev:serverUrl": "auto",
+ "author": {
+ "name": "afkarxyz",
+ "email": "hi@afkarxyz.fun"
+ },
+ "info": {
+ "companyName": "afkarxyz",
+ "productName": "SpotiFLAC",
+ "productVersion": "5.5",
+ "copyright": "Copyright © 2025",
+ "comments": "Get Spotify tracks in true FLAC from Tidal/Deezer — no account required."
+ },
+ "wailsjsdir": "./frontend",
+ "assetdir": "./frontend/dist",
+ "reloaddirs": "./frontend/src"
+}