diff --git a/app/core/main_manager.py b/app/core/main_manager.py index eb78748..b6f9077 100644 --- a/app/core/main_manager.py +++ b/app/core/main_manager.py @@ -5,8 +5,6 @@ from app.core.settings_manager import SettingsManager from app.core.alert_manager import AlertManager from app.core.update_manager import UpdateManager from app.core.license_manager import LicenseManager -from app.core.youtube_manager import YoutubeManager -from app.core.metadata_manager import MetadataManager from typing import Optional @@ -25,8 +23,6 @@ class MainManager: self.alert_manager: AlertManager = AlertManager(self.language_manager, self.theme_manager) self.update_manager: UpdateManager = UpdateManager(self.settings_manager, self.language_manager, self.alert_manager) self.license_manager: LicenseManager = LicenseManager(self.settings_manager) - self.youtube_manager: YoutubeManager = YoutubeManager(self.settings_manager) - self.metadata_manager: MetadataManager = MetadataManager() @classmethod def get_instance(cls) -> 'MainManager': if cls._instance is None: @@ -52,10 +48,4 @@ class MainManager: return self.update_manager def get_license_manager(self) -> LicenseManager: - return self.license_manager - - def get_youtube_manager(self) -> YoutubeManager: - return self.youtube_manager - - def get_metadata_manager(self) -> MetadataManager: - return self.metadata_manager \ No newline at end of file + return self.license_manager \ No newline at end of file diff --git a/app/core/metadata_manager.py b/app/core/metadata_manager.py deleted file mode 100644 index 3c55ad5..0000000 --- a/app/core/metadata_manager.py +++ /dev/null @@ -1,82 +0,0 @@ -from pathlib import Path -import re -from mutagen.id3 import ID3, TALB, TIT2, TPE1, delete as id3_delete - -class MetadataManager: - def apply_mp3_tags(self, file_path: Path, metadata: dict, fallback_title: str) -> None: - track_title = str(metadata.get("mp3_title") or metadata.get("title") or fallback_title).strip() - track_artist = str(metadata.get("artist", "")).strip() - track_album = str(metadata.get("album", "")).strip() - track_cover = str(metadata.get("cover_path", "")).strip() - - try: - id3_delete(str(file_path)) - except Exception: - pass - - tags = ID3() - tags.delall("TIT2") - tags.add(TIT2(encoding=3, text=track_title or fallback_title)) - tags.delall("TPE1") - if track_artist: - tags.add(TPE1(encoding=3, text=track_artist)) - tags.delall("TALB") - if track_album: - tags.add(TALB(encoding=3, text=track_album)) - tags.save(str(file_path), v2_version=4) - - # Use eyeD3 for cover art (Windows Explorer compatibility) - if track_cover: - try: - import eyed3 - from PIL import Image - import io - audiofile = eyed3.load(str(file_path)) - if audiofile is not None: - if audiofile.tag is None: - audiofile.initTag() - cover_file = Path(track_cover) - if cover_file.exists() and cover_file.is_file(): - # Always convert to JPEG and resize to 999x999 - try: - img = Image.open(cover_file) - img = img.convert("RGB") - img = img.resize((999, 999), Image.LANCZOS) - buf = io.BytesIO() - img.save(buf, format="JPEG") - image_data = buf.getvalue() - mime_type = "image/jpeg" - except Exception as e: - print(f"[eyeD3] Failed to process image: {e}") - image_data = cover_file.read_bytes() - mime_type = "image/jpeg" - audiofile.tag.images.set(3, image_data, mime_type, u"Cover") - audiofile.tag.version = (2, 3, 0) - audiofile.tag.save(version=(2, 3, 0)) - except Exception as e: - print(f"[eyeD3] Failed to embed cover: {e}") - - def sanitize_filename_part(self, value: str) -> str: - clean_value = re.sub(r"[<>:\"/\\|?*]", "_", value).strip() - clean_value = re.sub(r"\s+", " ", clean_value) - return clean_value.strip(". ") - - def rename_output_file(self, file_path: Path, title: str, artist: str) -> Path: - safe_title = self.sanitize_filename_part(title) - safe_artist = self.sanitize_filename_part(artist) - if safe_artist and safe_title: - base_name = f"{safe_artist} - {safe_title}" - else: - base_name = safe_title or self.sanitize_filename_part(file_path.stem) - target_path = file_path.with_name(f"{base_name}{file_path.suffix}") - if target_path == file_path: - return file_path - counter = 2 - while target_path.exists(): - target_path = file_path.with_name(f"{base_name} ({counter}){file_path.suffix}") - counter += 1 - try: - file_path.rename(target_path) - return target_path - except Exception: - return file_path diff --git a/app/core/settings_manager.py b/app/core/settings_manager.py index e035633..c9e138b 100644 --- a/app/core/settings_manager.py +++ b/app/core/settings_manager.py @@ -178,43 +178,4 @@ class SettingsManager: try: self.settings.setValue("maximized", maximized) except Exception as e: - logger.error(f"Error setting maximized state: {e}") - - # Audio download settings - def get_audio_format(self) -> str: - """Get preferred audio output format""" - try: - value = self.settings.value("audio_format", self.default_settings.get("audio_format", "mp3")) - if value in ("mp3", "best"): - return value - return "mp3" - except Exception as e: - logger.error(f"Error getting audio format: {e}") - return "mp3" - - def set_audio_format(self, audio_format: str) -> None: - """Set preferred audio output format""" - try: - if audio_format in ("mp3", "best") and audio_format != self.get_audio_format(): - self.settings.setValue("audio_format", audio_format) - except Exception as e: - logger.error(f"Error setting audio format: {e}") - - def get_audio_quality(self) -> str: - """Get preferred audio quality for mp3 format""" - try: - value = self.settings.value("audio_quality", self.default_settings.get("audio_quality", "320")) - if value in ("320", "192", "128"): - return value - return "320" - except Exception as e: - logger.error(f"Error getting audio quality: {e}") - return "320" - - def set_audio_quality(self, quality: str) -> None: - """Set preferred audio quality for mp3 format""" - try: - if quality in ("320", "192", "128") and quality != self.get_audio_quality(): - self.settings.setValue("audio_quality", quality) - except Exception as e: - logger.error(f"Error setting audio quality: {e}") \ No newline at end of file + logger.error(f"Error setting maximized state: {e}") \ No newline at end of file diff --git a/app/core/theme_manager.py b/app/core/theme_manager.py index d42f4b9..f603464 100644 --- a/app/core/theme_manager.py +++ b/app/core/theme_manager.py @@ -82,7 +82,6 @@ class ThemeManager: background-color: {self.current_theme.get_color("background_tertiary_color")}; color: {self.current_theme.get_color("text_color")}; }} - QDateEdit {{ border: 2px solid {self.current_theme.get_color("border_color")}; padding: 5px; @@ -90,10 +89,6 @@ class ThemeManager: font-size: 14px; min-height: 30px; }} - QDateEdit::drop-down {{ - border: none; - background: transparent; - }} QDateEdit:hover {{ border-color: {self.current_theme.get_color("primary_hover_color")}; }} @@ -127,9 +122,6 @@ class ThemeManager: border: none; background: transparent; }} - QComboBox::down-arrow {{ - image: none; - }} QComboBox:hover {{ border-color: {self.current_theme.get_color("primary_hover_color")}; }} @@ -215,4 +207,15 @@ class ThemeManager: #tab_bar {{ background-color: {self.current_theme.get_color("background_color")}; }} + + #icon_label {{ + background: transparent; + border: none; + }} + + #text_label {{ + font-size: 14px; + background: transparent; + border: none; + }} """ \ No newline at end of file diff --git a/app/core/youtube_manager.py b/app/core/youtube_manager.py deleted file mode 100644 index 0915bb6..0000000 --- a/app/core/youtube_manager.py +++ /dev/null @@ -1,575 +0,0 @@ -from pathlib import Path -import re -from urllib.parse import quote_plus -from typing import Optional - -from PyQt6.QtCore import QObject, QThread, pyqtSignal -from mutagen.id3 import APIC, ID3, TALB, TIT2, TPE1, delete as id3_delete -from yt_dlp import YoutubeDL - -import app.utils.paths as paths -from app.core.settings_manager import SettingsManager - - -class AudioDownloadWorker(QThread): - progress_text = pyqtSignal(str) - progress_value = pyqtSignal(int) - completed = pyqtSignal(str) - failed = pyqtSignal(str) - - def __init__(self, url: str, output_dir: str, audio_format: str, audio_quality: str, ffmpeg_location: Optional[str] = None, track_metadata: Optional[dict] = None) -> None: - super().__init__() - self.url = url - self.output_dir = output_dir - self.audio_format = audio_format - self.audio_quality = audio_quality - self.ffmpeg_location = ffmpeg_location - self.track_metadata = track_metadata if isinstance(track_metadata, dict) else {} - - def _progress_hook(self, data: dict) -> None: - status = data.get("status") - - if status == "downloading": - percent_text = str(data.get("_percent_str", "")).strip() - percent_text = re.sub(r"\x1b\[[0-9;]*m", "", percent_text) - percent_match = re.search(r"(\d+(?:\.\d+)?)%", percent_text) - - if percent_match: - value = int(float(percent_match.group(1))) - self.progress_value.emit(max(0, min(100, value))) - - self.progress_text.emit(percent_text or "Downloading...") - - elif status == "finished": - self.progress_value.emit(100) - - def run(self) -> None: - try: - ydl_opts = { - "format": "bestaudio/best", - "noplaylist": True, - "outtmpl": str(Path(self.output_dir) / "%(title)s.%(ext)s"), - "progress_hooks": [self._progress_hook], - "quiet": True, - "no_warnings": True, - } - - if self.ffmpeg_location: - ydl_opts["ffmpeg_location"] = self.ffmpeg_location - - if self.audio_format == "mp3": - ydl_opts.update( - { - "postprocessors": [ - { - "key": "FFmpegExtractAudio", - "preferredcodec": "mp3", - "preferredquality": self.audio_quality, - } - ] - } - ) - - with YoutubeDL(ydl_opts) as ydl: - info = ydl.extract_info(self.url, download=True) - title = info.get("title", "audio") - self.progress_text.emit(title) - - if self.audio_format == "mp3": - downloaded_file = self._resolve_downloaded_file(info) - if downloaded_file: - self._apply_mp3_tags(downloaded_file, title) - - self.completed.emit(title) - except Exception as error: - self.failed.emit(str(error)) - - def _resolve_downloaded_file(self, info: dict) -> Optional[Path]: - candidates: list[Path] = [] - - requested_downloads = info.get("requested_downloads") if isinstance(info, dict) else None - if isinstance(requested_downloads, list): - for download_data in requested_downloads: - if not isinstance(download_data, dict): - continue - for key in ("filepath", "_filename"): - candidate_path = download_data.get(key) - if isinstance(candidate_path, str) and candidate_path: - candidates.append(Path(candidate_path)) - - for key in ("filepath", "_filename"): - candidate_path = info.get(key) if isinstance(info, dict) else None - if isinstance(candidate_path, str) and candidate_path: - candidates.append(Path(candidate_path)) - - for candidate in candidates: - if candidate.exists() and candidate.is_file() and candidate.suffix.lower() == ".mp3": - return candidate - mp3_candidate = candidate.with_suffix(".mp3") - if mp3_candidate.exists() and mp3_candidate.is_file(): - return mp3_candidate - - output_dir = Path(self.output_dir) - if output_dir.exists(): - mp3_files = sorted(output_dir.glob("*.mp3"), key=lambda file_path: file_path.stat().st_mtime, reverse=True) - if mp3_files: - return mp3_files[0] - return None - - def _apply_mp3_tags(self, file_path: Path, fallback_title: str) -> None: - from app.core.metadata_manager import MetadataManager - metadata_manager = MetadataManager() - metadata_manager.apply_mp3_tags(file_path, self.track_metadata, fallback_title) - renamed_file = metadata_manager.rename_output_file(file_path, self.track_metadata.get("mp3_title") or fallback_title, self.track_metadata.get("artist", "")) - if renamed_file is not None: - self.progress_text.emit(renamed_file.stem) - - -class YoutubeSearchWorker(QThread): - finished_search = pyqtSignal(list) - failed_search = pyqtSignal(str) - - def __init__(self, query: str) -> None: - super().__init__() - self.query = query - - def run(self) -> None: - try: - results: list[dict] = [] - seen_urls: set[str] = set() - ydl_opts = {"quiet": True, "no_warnings": True, "extract_flat": True} - - with YoutubeDL(ydl_opts) as ydl: - video_result = ydl.extract_info(f"ytsearch10:{self.query}", download=False) - playlist_url = f"https://www.youtube.com/results?search_query={quote_plus(self.query)}&sp=EgIQAw%253D%253D" - playlist_result = ydl.extract_info(playlist_url, download=False) - - video_entries = video_result.get("entries", []) if isinstance(video_result, dict) else [] - for entry in video_entries: - item = self._normalize_video_entry(entry) - if item and item["url"] not in seen_urls: - seen_urls.add(item["url"]) - results.append(item) - - playlist_entries = playlist_result.get("entries", []) if isinstance(playlist_result, dict) else [] - for entry in playlist_entries: - item = self._normalize_playlist_entry(entry) - if item and item["url"] not in seen_urls: - seen_urls.add(item["url"]) - results.append(item) - - self.finished_search.emit(results) - except Exception: - self.failed_search.emit("search_failed") - - def _normalize_video_entry(self, entry: dict) -> Optional[dict]: - if not isinstance(entry, dict): - return None - - video_id = entry.get("id") - if not video_id: - return None - title = entry.get("title") or "Untitled" - channel = entry.get("uploader") or entry.get("channel") or "" - raw_duration = int(entry.get("duration")) - duration = self._format_duration(raw_duration) - thumbnail_url = f"https://i.ytimg.com/vi/{video_id}/hqdefault.jpg" - - return { - "kind": "video", - "title": title, - "url": f"https://www.youtube.com/watch?v={video_id}", - "channel": channel, - "duration": duration, - "item_count": "", - "thumbnail_url": thumbnail_url, - "thumbnail_bytes": b"", - } - - def _normalize_playlist_entry(self, entry: dict) -> Optional[dict]: - if not isinstance(entry, dict): - return None - - playlist_id = entry.get("id") - if not playlist_id: - return None - - title = entry.get("title") or "Untitled playlist" - channel = entry.get("uploader") or entry.get("channel") or "" - count = entry.get("playlist_count") or entry.get("n_entries") or 0 - thumbnails = entry.get("thumbnails") or [] - thumbnail_url = "" - if thumbnails and isinstance(thumbnails, list): - thumbnail_url = (thumbnails[-1] or {}).get("url", "") - if not thumbnail_url: - thumbnail_url = f"https://i.ytimg.com/vi/{playlist_id}/hqdefault.jpg" - - return { - "kind": "playlist", - "title": title, - "url": f"https://www.youtube.com/playlist?list={playlist_id}", - "channel": channel, - "duration": "", - "item_count": str(count) if count else "", - "thumbnail_url": thumbnail_url, - "thumbnail_bytes": b"", - } - - def _format_duration(self, seconds: Optional[int]) -> str: - if not isinstance(seconds, int) or seconds < 0: - return "" - minutes, sec = divmod(seconds, 60) - hours, minutes = divmod(minutes, 60) - if hours: - return f"{hours}:{minutes:02}:{sec:02}" - return f"{minutes}:{sec:02}" - - -class PreviewStreamWorker(QThread): - finished_preview = pyqtSignal(str, str) - failed_preview = pyqtSignal(str) - - def __init__(self, url: str, title: str) -> None: - super().__init__() - self.url = url - self.title = title - - def run(self) -> None: - try: - ydl_opts = { - "quiet": True, - "no_warnings": True, - "format": "best[height<=720][vcodec!=none][acodec!=none]/best[ext=mp4][height<=720]/best", - "noplaylist": True, - } - with YoutubeDL(ydl_opts) as ydl: - info = ydl.extract_info(self.url, download=False) - stream_url = info.get("url") if isinstance(info, dict) else None - if stream_url: - self.finished_preview.emit(stream_url, self.title) - else: - self.failed_preview.emit("preview_unavailable") - except Exception: - self.failed_preview.emit("preview_unavailable") - - -class YoutubeManager(QObject): - def apply_album_cover(self, album_name: str, cover_path: str) -> None: - """Applique la cover à tous les morceaux de l'album (business logic)""" - for idx, track in enumerate(self.track_list): - if str(track.get("album", "")).strip() == album_name: - self.update_track_metadata( - idx, - str(track.get("mp3_title") or track.get("title", "")), - str(track.get("artist", "")), - album_name, - cover_path, - ) - started = pyqtSignal() - progress_text = pyqtSignal(str) - progress_value = pyqtSignal(int) - completed = pyqtSignal(str) - failed = pyqtSignal(str) - list_changed = pyqtSignal(list) - queue_finished = pyqtSignal() - downloading_state_changed = pyqtSignal(bool) - search_started = pyqtSignal() - search_results = pyqtSignal(list) - preview_started = pyqtSignal() - preview_ready = pyqtSignal(str, str) - preview_failed = pyqtSignal(str) - - def __init__(self, settings_manager: SettingsManager) -> None: - super().__init__() - self.settings_manager = settings_manager - self.download_worker: Optional[AudioDownloadWorker] = None - self.search_worker: Optional[YoutubeSearchWorker] = None - self.preview_worker: Optional[PreviewStreamWorker] = None - self.track_list: list[dict[str, str]] = [] - self.download_queue: list[dict[str, str]] = [] - self.queue_output_dir: str = "" - self.last_playlist_added_count: int = 0 - - def get_tracks(self) -> list[dict[str, str]]: - return list(self.track_list) - - def _build_track_payload(self, title: str, url: str, artist: str = "", album: str = "", cover_path: str = "") -> dict[str, str]: - clean_title = title.strip() - clean_url = url.strip() - return { - "title": clean_title, - "url": clean_url, - "mp3_title": clean_title, - "artist": artist.strip(), - "album": album.strip(), - "cover_path": cover_path.strip(), - } - - def add_track(self, url: str) -> bool: - clean_url = url.strip() - if not self.is_valid_youtube_url(clean_url): - self.failed.emit("invalid_youtube_url") - return False - - title = self._extract_title_from_url(clean_url) - if not title: - self.failed.emit("metadata_fetch_failed") - return False - - self.track_list.append(self._build_track_payload(title=title, url=clean_url)) - self.list_changed.emit(self.get_tracks()) - return True - - def add_search_result(self, search_result: dict) -> bool: - self.last_playlist_added_count = 0 - kind = search_result.get("kind", "video") - url = search_result.get("url", "") - title = search_result.get("title", "") - - if kind == "playlist": - tracks = self._extract_playlist_tracks(url) - if not tracks: - self.failed.emit("playlist_expand_failed") - return False - - self.track_list.extend(tracks) - self.last_playlist_added_count = len(tracks) - self.list_changed.emit(self.get_tracks()) - return True - - return self.add_track_with_title(url, title) - - def add_track_with_title(self, url: str, title: str) -> bool: - clean_url = url.strip() - clean_title = title.strip() - - if not self.is_valid_youtube_url(clean_url): - self.failed.emit("invalid_youtube_url") - return False - - if not clean_title: - self.failed.emit("metadata_fetch_failed") - return False - - self.track_list.append(self._build_track_payload(title=clean_title, url=clean_url)) - self.list_changed.emit(self.get_tracks()) - return True - - def update_track_metadata(self, index: int, mp3_title: str, artist: str, album: str, cover_path: str) -> bool: - if index < 0 or index >= len(self.track_list): - self.failed.emit("no_track_selected") - return False - - track = self.track_list[index] - track["mp3_title"] = mp3_title.strip() - track["artist"] = artist.strip() - track["album"] = album.strip() - track["cover_path"] = cover_path.strip() - self.list_changed.emit(self.get_tracks()) - return True - - def search_tracks(self, query: str) -> bool: - clean_query = query.strip() - if len(clean_query) < 2: - self.failed.emit("invalid_search_query") - return False - - if self.search_worker and self.search_worker.isRunning(): - self.failed.emit("search_in_progress") - return False - - self.search_worker = YoutubeSearchWorker(clean_query) - self.search_worker.finished_search.connect(self.search_results.emit) - self.search_worker.failed_search.connect(self.failed.emit) - - self.search_started.emit() - self.search_worker.start() - return True - - def start_preview(self, search_result: dict) -> bool: - url = search_result.get("url", "") - title = search_result.get("title", "") - kind = search_result.get("kind", "video") - - if kind != "video": - self.preview_failed.emit("preview_playlist_unavailable") - return False - - if not self.is_valid_youtube_url(url): - self.preview_failed.emit("invalid_youtube_url") - return False - - if self.preview_worker and self.preview_worker.isRunning(): - self.preview_failed.emit("preview_in_progress") - return False - - self.preview_worker = PreviewStreamWorker(url, title) - self.preview_worker.finished_preview.connect(self.preview_ready.emit) - self.preview_worker.failed_preview.connect(self.preview_failed.emit) - self.preview_started.emit() - self.preview_worker.start() - return True - - def remove_track(self, index: int) -> bool: - if index < 0 or index >= len(self.track_list): - self.failed.emit("no_track_selected") - return False - - self.track_list.pop(index) - self.list_changed.emit(self.get_tracks()) - return True - - def clear_tracks(self) -> None: - self.track_list.clear() - self.list_changed.emit(self.get_tracks()) - - def download_track_at(self, index: int, output_dir: str) -> bool: - if index < 0 or index >= len(self.track_list): - self.failed.emit("no_track_selected") - return False - return self.download_audio(self.track_list[index]["url"], output_dir, self.track_list[index]) - - def download_all_tracks(self, output_dir: str) -> bool: - if not self.track_list: - self.failed.emit("empty_download_list") - return False - - if not output_dir or not Path(output_dir).exists(): - self.failed.emit("invalid_output_folder") - return False - - if self.download_worker and self.download_worker.isRunning(): - self.failed.emit("download_in_progress") - return False - - self.download_queue = list(self.track_list) - self.queue_output_dir = output_dir - self._download_next_in_queue() - return True - - def download_audio(self, url: str, output_dir: str, track_metadata: Optional[dict] = None) -> bool: - if not self.is_valid_youtube_url(url): - self.failed.emit("invalid_youtube_url") - return False - - if not output_dir or not Path(output_dir).exists(): - self.failed.emit("invalid_output_folder") - return False - - if self.download_worker and self.download_worker.isRunning(): - self.failed.emit("download_in_progress") - return False - - audio_format = self.settings_manager.get_audio_format() - audio_quality = self.settings_manager.get_audio_quality() - ffmpeg_location = self.resolve_ffmpeg_location() - - self.download_worker = AudioDownloadWorker(url, output_dir, audio_format, audio_quality, ffmpeg_location, track_metadata) - self.download_worker.progress_text.connect(self.progress_text.emit) - self.download_worker.progress_value.connect(self.progress_value.emit) - self.download_worker.completed.connect(self._on_worker_completed) - self.download_worker.failed.connect(self._on_worker_failed) - - self.started.emit() - self.downloading_state_changed.emit(True) - self.download_worker.start() - return True - - def _download_next_in_queue(self) -> None: - if not self.download_queue: - self.queue_output_dir = "" - self.downloading_state_changed.emit(False) - self.queue_finished.emit() - return - - next_track = self.download_queue.pop(0) - self.download_audio(next_track["url"], self.queue_output_dir, next_track) - - def _on_worker_completed(self, title: str) -> None: - self.completed.emit(title) - - if self.download_queue: - self._download_next_in_queue() - return - - self.downloading_state_changed.emit(False) - - def _on_worker_failed(self, error_text: str) -> None: - self.download_queue = [] - self.queue_output_dir = "" - self.downloading_state_changed.emit(False) - if "ffmpeg" in error_text.lower(): - self.failed.emit("ffmpeg_required") - else: - self.failed.emit("audio_download_error") - - def is_valid_youtube_url(self, url: str) -> bool: - if not url.startswith(("http://", "https://")): - return False - return "youtube.com" in url or "youtu.be" in url - - def _extract_title_from_url(self, url: str) -> Optional[str]: - try: - ydl_opts = { - "quiet": True, - "no_warnings": True, - } - with YoutubeDL(ydl_opts) as ydl: - info = ydl.extract_info(url, download=False) - return info.get("title") if isinstance(info, dict) else None - except Exception: - return None - - def _extract_playlist_tracks(self, playlist_url: str) -> list[dict[str, str]]: - # Try with extract_flat first, fallback to full extraction if it fails - for flat in (True, False): - try: - ydl_opts = { - "quiet": True, - "no_warnings": True, - } - if flat: - ydl_opts["extract_flat"] = True - with YoutubeDL(ydl_opts) as ydl: - info = ydl.extract_info(playlist_url, download=False) - - entries = info.get("entries", []) if isinstance(info, dict) else [] - tracks: list[dict[str, str]] = [] - for entry in entries: - if not isinstance(entry, dict): - continue - - video_id = entry.get("id") - title = entry.get("title") or "Untitled" - url = entry.get("url") - - if video_id: - video_url = f"https://www.youtube.com/watch?v={video_id}" - elif isinstance(url, str) and url.startswith("http"): - video_url = url - elif isinstance(url, str): - video_url = f"https://www.youtube.com/watch?v={url}" - else: - continue - - tracks.append(self._build_track_payload(title=title, url=video_url)) - - if tracks: - return tracks - except Exception as e: - print(f"[yt-dlp playlist extraction error] flat={flat}: {e}") - return [] - - def resolve_ffmpeg_location(self) -> Optional[str]: - candidates = [ - Path("others") / "ffmpeg.exe", - Path("data") / "others" / "ffmpeg.exe", - Path(paths.resource_path("others/ffmpeg.exe")), - Path(paths.resource_path("data/others/ffmpeg.exe")), - ] - - for candidate in candidates: - if candidate.exists(): - return str(candidate.parent) - - return None \ No newline at end of file diff --git a/app/ui/layouts/flow_layout.py b/app/ui/layouts/flow_layout.py new file mode 100644 index 0000000..04613bd --- /dev/null +++ b/app/ui/layouts/flow_layout.py @@ -0,0 +1,279 @@ +from enum import Enum + +from PyQt6.QtCore import Qt, QRect, QSize, QPoint, QMargins +from PyQt6.QtWidgets import QLayout + + +class JustifyMode(Enum): + NONE = 0 + SPACE_BETWEEN = 1 + SPACE_AROUND = 2 + SPACE_EVENLY = 3 + CENTER = 4 + + +class LastRowMode(Enum): + LEFT = 0 + JUSTIFY = 1 + MATCH_PREVIOUS = 2 + CENTER = 3 + + +class FlowLayout(QLayout): + def __init__( + self, + parent=None, + justify_mode=JustifyMode.SPACE_BETWEEN, + last_row_mode=LastRowMode.LEFT, + ): + super().__init__(parent) + + if parent is not None: + self.setContentsMargins(QMargins(0, 0, 0, 0)) + + self._items = [] + self.justify_mode = justify_mode + self.last_row_mode = last_row_mode + + # ------------------------------------------------------------------ + # QLayout API + # ------------------------------------------------------------------ + + def addItem(self, item): + self._items.append(item) + + def count(self): + return len(self._items) + + def itemAt(self, index): + if 0 <= index < len(self._items): + return self._items[index] + return None + + def takeAt(self, index): + if 0 <= index < len(self._items): + return self._items.pop(index) + return None + + def expandingDirections(self): + return Qt.Orientation(0) + + def hasHeightForWidth(self): + return True + + def heightForWidth(self, width): + return self._do_layout(QRect(0, 0, width, 0), True) + + def setGeometry(self, rect): + super().setGeometry(rect) + self._do_layout(rect, False) + + def sizeHint(self): + return self.minimumSize() + + def minimumSize(self): + size = QSize() + + for item in self._items: + size = size.expandedTo(item.minimumSize()) + + margins = self.contentsMargins() + + size += QSize( + margins.left() + margins.right(), + margins.top() + margins.bottom(), + ) + + return size + + # ------------------------------------------------------------------ + # Internal + # ------------------------------------------------------------------ + + def _build_rows(self, available_width): + rows = [] + + current_row = [] + current_width = 0 + + base_spacing = max(0, self.spacing()) + + for item in self._items: + width = item.sizeHint().width() + + if not current_row: + current_row.append(item) + current_width = width + continue + + projected = current_width + base_spacing + width + + if projected > available_width: + rows.append(current_row) + + current_row = [item] + current_width = width + else: + current_row.append(item) + current_width = projected + + if current_row: + rows.append(current_row) + + return rows + + def _layout_row( + self, + row, + rect, + y, + test_only, + justify_mode, + spacing_override=None, + ): + widths = [item.sizeHint().width() for item in row] + heights = [item.sizeHint().height() for item in row] + + total_width = sum(widths) + row_height = max(heights) + + count = len(row) + gaps = max(0, count - 1) + + available_width = rect.width() + + x = rect.x() + + used_spacing = self.spacing() + + # -------------------------------------------------------------- + # Explicit spacing override (MATCH_PREVIOUS) + # -------------------------------------------------------------- + + if spacing_override is not None: + used_spacing = spacing_override + + for item, w, h in zip(row, widths, heights): + if not test_only: + item.setGeometry(QRect(int(round(x)), y, w, h)) + + x += w + used_spacing + + return row_height, used_spacing + + # -------------------------------------------------------------- + # LEFT / NONE + # -------------------------------------------------------------- + + if justify_mode == JustifyMode.NONE: + used_spacing = max(0, self.spacing()) + + for item, w, h in zip(row, widths, heights): + if not test_only: + item.setGeometry(QRect(int(round(x)), y, w, h)) + + x += w + used_spacing + + return row_height, used_spacing + + # -------------------------------------------------------------- + # CENTER + # -------------------------------------------------------------- + + if justify_mode == JustifyMode.CENTER: + used_spacing = max(0, self.spacing()) + + content_width = total_width + + if count > 1: + content_width += gaps * used_spacing + + x += max(0, (available_width - content_width) / 2) + + for item, w, h in zip(row, widths, heights): + if not test_only: + item.setGeometry(QRect(int(round(x)), y, w, h)) + + x += w + used_spacing + + return row_height, used_spacing + + # -------------------------------------------------------------- + # SPACE_* + # -------------------------------------------------------------- + + free_space = max(0, available_width - total_width) + + if justify_mode == JustifyMode.SPACE_BETWEEN: + if gaps > 0: + used_spacing = free_space / gaps + else: + used_spacing = 0 + + elif justify_mode == JustifyMode.SPACE_AROUND: + used_spacing = free_space / count if count else 0 + x += used_spacing / 2 + + elif justify_mode == JustifyMode.SPACE_EVENLY: + used_spacing = free_space / (count + 1) if count else 0 + x += used_spacing + + for item, w, h in zip(row, widths, heights): + if not test_only: + item.setGeometry(QRect(int(round(x)), y, w, h)) + + x += w + used_spacing + + return row_height, used_spacing + + def _do_layout(self, rect, test_only): + rows = self._build_rows(rect.width()) + + y = rect.y() + + vertical_spacing = max(0, self.spacing()) + + previous_spacing = None + + for row_index, row in enumerate(rows): + is_last_row = row_index == len(rows) - 1 + + spacing_override = None + row_mode = self.justify_mode + + if is_last_row: + + if self.last_row_mode == LastRowMode.LEFT: + row_mode = JustifyMode.NONE + + elif self.last_row_mode == LastRowMode.CENTER: + row_mode = JustifyMode.CENTER + + elif self.last_row_mode == LastRowMode.JUSTIFY: + row_mode = self.justify_mode + + elif ( + self.last_row_mode == LastRowMode.MATCH_PREVIOUS + and previous_spacing is not None + ): + spacing_override = previous_spacing + row_mode = JustifyMode.NONE + + row_height, used_spacing = self._layout_row( + row=row, + rect=rect, + y=y, + test_only=test_only, + justify_mode=row_mode, + spacing_override=spacing_override, + ) + + previous_spacing = used_spacing + + y += row_height + vertical_spacing + + if rows: + y -= vertical_spacing + + return y - rect.y() + \ No newline at end of file diff --git a/app/ui/main_window.py b/app/ui/main_window.py index a10dd08..d85923c 100644 --- a/app/ui/main_window.py +++ b/app/ui/main_window.py @@ -1,4 +1,4 @@ -from PyQt6.QtWidgets import QApplication, QMainWindow, QLabel +from PyQt6.QtWidgets import QApplication, QMainWindow, QLabel, QWidget from PyQt6.QtGui import QResizeEvent, QCloseEvent from PyQt6.QtCore import QSize, QEvent from app.core.main_manager import MainManager, NotificationType @@ -6,8 +6,7 @@ from app.ui.widgets.tabs_widget import TabsWidget, MenuDirection, ButtonPosition from app.ui.windows.settings_window import SettingsWindow from app.ui.windows.suggestion_window import SuggestionWindow from app.ui.windows.activation_window import ActivationWindow -from app.ui.windows.add_music_window import AddMusicWindow -from app.ui.windows.download_list_window import DownloadListWindow +from app.ui.windows.editing_window import EditingWindow import app.utils.paths as paths, shutil from typing import Optional @@ -204,6 +203,9 @@ class MainWindow(QMainWindow): for widget_type in widget_types: for child in widget.findChildren(widget_type): + + if child.accessibleName() == "ignore": + continue # Ignorer les widgets marqués pour être ignorés widget_id = id(child) # Récupérer la taille de base @@ -251,22 +253,20 @@ class MainWindow(QMainWindow): self.side_menu = TabsWidget(self, MenuDirection.HORIZONTAL, 70, None, 10, BorderSide.BOTTOM, TabSide.TOP) - self.add_music_window = AddMusicWindow(self) - self.add_music_tab_index = self.side_menu.add_widget(self.add_music_window, self.language_manager.get_text("tab_add_music"), None, position=ButtonPosition.CENTER, text_position=TextPosition.BOTTOM) + self.editing_window = EditingWindow(self) + self.side_menu.add_widget(self.editing_window, self.language_manager.get_text("tab_editing"), paths.get_asset_svg_path("edit"), position=ButtonPosition.CENTER, text_position=TextPosition.BOTTOM) + - self.download_list_window = DownloadListWindow(self) - self.download_list_tab_index = self.side_menu.add_widget(self.download_list_window, self.language_manager.get_text("tab_download_list"), None, position=ButtonPosition.CENTER, text_position=TextPosition.BOTTOM) - self.suggestion_window = SuggestionWindow(self) - self.suggestion_tab_index = self.side_menu.add_widget(self.suggestion_window, self.language_manager.get_text("tab_suggestions"), paths.get_asset_svg_path("suggestion"), position=ButtonPosition.CENTER, text_position=TextPosition.BOTTOM) + self.side_menu.add_widget(self.suggestion_window, self.language_manager.get_text("tab_suggestions"), paths.get_asset_svg_path("suggestion"), position=ButtonPosition.CENTER, text_position=TextPosition.BOTTOM) self.settings_window = SettingsWindow(self) - self.settings_tab_index = self.side_menu.add_widget(self.settings_window, self.language_manager.get_text("tab_settings"), paths.get_asset_svg_path("settings"), position=ButtonPosition.CENTER, text_position=TextPosition.BOTTOM) + self.side_menu.add_widget(self.settings_window, self.language_manager.get_text("tab_settings"), paths.get_asset_svg_path("settings"), position=ButtonPosition.CENTER, text_position=TextPosition.BOTTOM) # Ajouter la tab d'activation uniquement si le système de licence est activé if self.settings_manager.get_config("enable_licensing"): self.activation_window = ActivationWindow(self) - self.licensing_tab_index = self.side_menu.add_widget(self.activation_window, self.language_manager.get_text("tab_licensing"), paths.get_asset_svg_path("license"), position=ButtonPosition.END, text_position=TextPosition.BOTTOM) + self.side_menu.add_widget(self.activation_window, self.language_manager.get_text("tab_licensing"), paths.get_asset_svg_path("license"), position=ButtonPosition.END, text_position=TextPosition.BOTTOM) self.setCentralWidget(self.side_menu) @@ -279,9 +279,7 @@ class MainWindow(QMainWindow): def update_language(self) -> None: # Mettre à jour les textes des onglets - self.side_menu.update_button_text(self.suggestion_tab_index, self.language_manager.get_text("tab_suggestions")) - self.side_menu.update_button_text(self.add_music_tab_index, self.language_manager.get_text("tab_add_music")) - self.side_menu.update_button_text(self.download_list_tab_index, self.language_manager.get_text("tab_download_list")) - self.side_menu.update_button_text(self.settings_tab_index, self.language_manager.get_text("tab_settings")) + self.side_menu.update_button_text(0, self.language_manager.get_text("tab_suggestions")) + self.side_menu.update_button_text(1, self.language_manager.get_text("tab_settings")) if self.settings_manager.get_config("enable_licensing"): - self.side_menu.update_button_text(self.licensing_tab_index, self.language_manager.get_text("tab_licensing")) \ No newline at end of file + self.side_menu.update_button_text(2, self.language_manager.get_text("tab_licensing")) \ No newline at end of file diff --git a/app/ui/widgets/grid_element.py b/app/ui/widgets/grid_element.py new file mode 100644 index 0000000..83f9ddb --- /dev/null +++ b/app/ui/widgets/grid_element.py @@ -0,0 +1,48 @@ +from PyQt6.QtWidgets import QWidget, QLabel, QVBoxLayout +from PyQt6.QtGui import QPixmap + + +class GridElement(QWidget): + def __init__(self, parent: QWidget = None, name: str = None, image: str = "", linked_object: object = None) -> None: + super().__init__(parent) + self.image = QPixmap(image) + self.image_path = image + self.name = name + self.linked_object = linked_object + self.setup_ui() + + def setup_ui(self) -> None: + layout = QVBoxLayout(self) + self.image_label = QLabel(self) + self.image_label.setPixmap(self.image) + self.name_label = QLabel(self.name, self) + self.name_label.setAccessibleName("ignore") + self.name_label.setObjectName("text_label") + layout.addWidget(self.image_label) + layout.addWidget(self.name_label) + self.set_image_size(70, 70) # Set default image size + def set_image(self, image: str) -> None: + self.image = QPixmap(image) + self.image_path = image + + def set_name(self, name: str) -> None: + self.name = name + + def set_linked_object(self, linked_object: object) -> None: + self.linked_object = linked_object + + def set_image_size(self, width: int, height: int) -> None: + self.image = self.image.scaled(width, height) + self.image_label.setPixmap(self.image) + + def get_image(self) -> QPixmap: + return self.image + + def get_image_path(self) -> str: + return self.image_path + + def get_name(self) -> str: + return self.name + + def get_linked_object(self) -> object: + return self.linked_object \ No newline at end of file diff --git a/app/ui/widgets/grid_widget.py b/app/ui/widgets/grid_widget.py new file mode 100644 index 0000000..ad06fcd --- /dev/null +++ b/app/ui/widgets/grid_widget.py @@ -0,0 +1,30 @@ +from PyQt6.QtWidgets import QWidget +from PyQt6.QtCore import Qt +from app.ui.layouts.flow_layout import FlowLayout, JustifyMode, LastRowMode +from app.ui.widgets.grid_element import GridElement +class GridWidget(QWidget): + def __init__(self, elements: list[GridElement] = [], parent=None) -> None: + super().__init__(parent) + self.elements = elements + + self.setup_ui() + + def setup_ui(self) -> None: + layout = FlowLayout(self, justify_mode=JustifyMode.SPACE_BETWEEN, last_row_mode=LastRowMode.MATCH_PREVIOUS) + layout.setAlignment(Qt.AlignmentFlag.AlignCenter) + if self.elements: + for element in self.elements: + layout.addWidget(element) + else: + for _ in range(5000): # Add 58 placeholder elements + layout.addWidget(GridElement(self, name="Placeholder", image="data/assets/icon.png")) + + + def add_element(self, element: GridElement) -> None: + layout: FlowLayout = self.layout() + layout.addWidget(element) + + def remove_element(self, element: GridElement) -> None: + layout: FlowLayout = self.layout() + layout.removeWidget(element) + element.setParent(None) diff --git a/app/ui/widgets/tabs_widget.py b/app/ui/widgets/tabs_widget.py index 1ad226b..6e7b60c 100644 --- a/app/ui/widgets/tabs_widget.py +++ b/app/ui/widgets/tabs_widget.py @@ -369,7 +369,7 @@ class TabsWidget(QWidget): # Store reference for resize updates self._square_buttons.append(button) - def eventFilter(self, obj, event): + def eventFilter(self, obj : QPushButton, event): """Handle hover events for buttons""" if obj in self.buttons: if event.type() == event.Type.Enter: @@ -728,9 +728,9 @@ class TabsWidget(QWidget): continue lname = tag.split('}')[-1] # local name if lname in ("svg", "g", "path", "rect", "circle", "ellipse", "polygon", "line", "polyline"): - if 'fill' in el.attrib: + if 'fill' in el.attrib and el.attrib['fill'] != 'none': el.attrib['fill'] = color - if 'stroke' in el.attrib: + if 'stroke' in el.attrib and el.attrib['stroke'] != 'none': el.attrib['stroke'] = color style = el.attrib.get('style') diff --git a/app/ui/windows/add_music_window.py b/app/ui/windows/add_music_window.py deleted file mode 100644 index 5ae1e07..0000000 --- a/app/ui/windows/add_music_window.py +++ /dev/null @@ -1,400 +0,0 @@ -from typing import Optional - -from PyQt6.QtCore import Qt, QUrl, QSize, pyqtSignal -from PyQt6.QtGui import QIcon, QPixmap -from PyQt6.QtMultimedia import QAudioOutput, QMediaPlayer -from PyQt6.QtMultimediaWidgets import QVideoWidget -from PyQt6.QtNetwork import QNetworkAccessManager, QNetworkReply, QNetworkRequest -from PyQt6.QtWidgets import ( - QHBoxLayout, - QLabel, - QLineEdit, - QListWidget, - QListWidgetItem, - QPushButton, - QStackedWidget, - QVBoxLayout, - QWidget, -) - -from app.core.main_manager import MainManager, NotificationType -from app.ui.widgets.loading_bar import LoadingBar - - -class AddMusicWindow(QWidget): - from PyQt6.QtCore import QThread, pyqtSignal - from app.ui.widgets.loading_bar import LoadingBar - - class PlaylistAddThread(QThread): - progress = pyqtSignal(int, int) - finished = pyqtSignal(bool, int) - failed = pyqtSignal(str) - def __init__(self, download_manager, url, parent=None): - super().__init__(parent) - self.download_manager = download_manager - self.url = url - def run(self): - try: - tracks = self.download_manager._extract_playlist_tracks(self.url) - total = len(tracks) - if not tracks: - self.failed.emit("playlist_expand_failed") - self.finished.emit(False, 0) - return - for i, track in enumerate(tracks, 1): - self.download_manager.track_list.append(track) - self.progress.emit(i, total) - self.download_manager.last_playlist_added_count = total - self.download_manager.list_changed.emit(self.download_manager.get_tracks()) - self.finished.emit(True, total) - except Exception as e: - self.failed.emit(str(e)) - self.finished.emit(False, 0) - RESULTS_VIEW_INDEX = 0 - PLAYER_VIEW_INDEX = 1 - - def __init__(self, parent: Optional[QWidget] = None) -> None: - super().__init__(parent) - self.main_manager = MainManager.get_instance() - self.language_manager = self.main_manager.get_language_manager() - self.alert_manager = self.main_manager.get_alert_manager() - self.youtube_manager = self.main_manager.get_youtube_manager() - self.metadata_manager = self.main_manager.get_metadata_manager() - - self.observer_manager = self.main_manager.get_observer_manager() - self.observer_manager.subscribe(NotificationType.LANGUAGE, self.update_language) - - self.youtube_manager.failed.connect(self.on_manager_error) - self.youtube_manager.search_started.connect(self.on_search_started) - self.youtube_manager.search_results.connect(self.on_search_results) - self.youtube_manager.preview_ready.connect(self.on_preview_ready) - self.youtube_manager.preview_failed.connect(self.on_preview_failed) - - self.thumbnail_manager = QNetworkAccessManager(self) - self.thumbnail_replies: dict[QNetworkReply, QListWidgetItem] = {} - self.current_video: Optional[dict] = None - - self.audio_output = QAudioOutput(self) - self.video_player = QMediaPlayer(self) - self.video_player.setAudioOutput(self.audio_output) - - self.player_widget = None - self.setup_ui() - # Always initialize search_results and related widgets - self.content_stack = QStackedWidget(self) - self.layout().addWidget(self.content_stack, 1) - - self.results_view = QWidget(self) - results_layout = QVBoxLayout(self.results_view) - results_layout.setContentsMargins(0, 0, 0, 0) - results_layout.setSpacing(0) - - self.search_results = QListWidget(self.results_view) - self.search_results.setSelectionMode(QListWidget.SelectionMode.SingleSelection) - self.search_results.setIconSize(QSize(120, 68)) - self.search_results.setSpacing(6) - self.search_results.itemClicked.connect(self.on_result_selected) - results_layout.addWidget(self.search_results) - - self.player_view = QWidget(self) - player_layout = QVBoxLayout(self.player_view) - player_layout.setContentsMargins(0, 0, 0, 0) - player_layout.setSpacing(10) - - player_top_row = QHBoxLayout() - self.back_to_results_button = QPushButton(self.language_manager.get_text("back_to_results"), self.player_view) - self.back_to_results_button.clicked.connect(self.go_back_to_results) - player_top_row.addWidget(self.back_to_results_button) - player_top_row.addStretch(1) - player_layout.addLayout(player_top_row) - - self.player_widget = self._create_player_widget() - player_layout.addWidget(self.player_widget, 1) - - actions_row = QHBoxLayout() - self.add_selected_button = QPushButton(self.language_manager.get_text("add_selected_result"), self) - self.add_selected_button.clicked.connect(self.add_selected_result) - self.add_selected_button.setEnabled(False) - actions_row.addStretch(1) - actions_row.addWidget(self.add_selected_button) - player_layout.addLayout(actions_row) - - self.content_stack.addWidget(self.results_view) - self.content_stack.addWidget(self.player_view) - self.content_stack.setCurrentIndex(self.RESULTS_VIEW_INDEX) - - if self.player_widget and isinstance(self.player_widget, QVideoWidget): - self.video_player.setVideoOutput(self.player_widget) - - def setup_ui(self) -> None: - layout = QVBoxLayout(self) - layout.setAlignment(Qt.AlignmentFlag.AlignTop) - layout.setSpacing(16) - layout.setContentsMargins(20, 20, 20, 20) - - search_row = QHBoxLayout() - self.search_input = QLineEdit(self) - self.search_input.setPlaceholderText(self.language_manager.get_text("search_placeholder")) - search_row.addWidget(self.search_input, 1) - - self.search_button = QPushButton(self.language_manager.get_text("search_youtube"), self) - self.search_button.clicked.connect(self.search_tracks) - search_row.addWidget(self.search_button) - - self.link_input = QLineEdit(self) - self.link_input.setPlaceholderText(self.language_manager.get_text("add_link_placeholder")) - search_row.addWidget(self.link_input, 1) - self.add_link_button = QPushButton(self.language_manager.get_text("add_link_button"), self) - self.add_link_button.clicked.connect(self.add_link) - search_row.addWidget(self.add_link_button) - layout.addLayout(search_row) - self.setLayout(layout) - - def add_link(self) -> None: - url = self.link_input.text().strip() - if not url: - self.alert_manager.show_error("invalid_link", self) - return - if "list=" in url: - self._show_loading_bar(self.language_manager.get_text("downloading_audio")) - # Always assign as self.playlist_thread and parent to self - self.playlist_thread = self.PlaylistAddThread(self.youtube_manager, url, parent=self) - self.playlist_thread.progress.connect(self.on_playlist_progress) - self.playlist_thread.finished.connect(self.on_playlist_finished) - self.playlist_thread.failed.connect(self.on_playlist_failed) - self.playlist_thread.start() - else: - if not self.youtube_manager.add_track(url): - self.alert_manager.show_error("invalid_link", self) - - def _show_loading_bar(self, label: str): - if hasattr(self, "loading_bar") and self.loading_bar is not None: - self.loading_bar.deleteLater() - self.loading_bar = LoadingBar(label, self) - self.layout().addWidget(self.loading_bar) - self.loading_bar.set_label(label) - self.loading_bar.set_progress(0) - - def on_playlist_progress(self, current, total): - if hasattr(self, "loading_bar") and self.loading_bar is not None: - self.loading_bar.set_label(f"Ajout {current}/{total} musiques...") - self.loading_bar.set_progress(int(current / total * 100) if total else 0) - - def on_playlist_finished(self, success, count): - if hasattr(self, "loading_bar") and self.loading_bar is not None: - self.loading_bar.set_label(self.language_manager.get_text("playlist_added_count").replace("{count}", str(count))) - self.loading_bar.set_progress(100) - self.loading_bar.deleteLater() - self.loading_bar = None - self.alert_manager.show_info(self.language_manager.get_text("playlist_added_count").replace("{count}", str(count)), self) - - def on_playlist_failed(self, error_key): - if hasattr(self, "loading_bar") and self.loading_bar is not None: - self.loading_bar.set_label(self.language_manager.get_text(error_key)) - self.loading_bar.set_progress(0) - self.loading_bar.deleteLater() - self.loading_bar = None - self.alert_manager.show_error(error_key, self) - - self.content_stack = QStackedWidget(self) - self.layout().addWidget(self.content_stack, 1) - - self.results_view = QWidget(self) - results_layout = QVBoxLayout(self.results_view) - results_layout.setContentsMargins(0, 0, 0, 0) - results_layout.setSpacing(0) - - self.search_results = QListWidget(self.results_view) - self.search_results.setSelectionMode(QListWidget.SelectionMode.SingleSelection) - self.search_results.setIconSize(QSize(120, 68)) - self.search_results.setSpacing(6) - self.search_results.itemClicked.connect(self.on_result_selected) - results_layout.addWidget(self.search_results) - - self.player_view = QWidget(self) - player_layout = QVBoxLayout(self.player_view) - player_layout.setContentsMargins(0, 0, 0, 0) - player_layout.setSpacing(10) - - player_top_row = QHBoxLayout() - self.back_to_results_button = QPushButton(self.language_manager.get_text("back_to_results"), self.player_view) - self.back_to_results_button.clicked.connect(self.go_back_to_results) - player_top_row.addWidget(self.back_to_results_button) - player_top_row.addStretch(1) - player_layout.addLayout(player_top_row) - - self.player_widget = self._create_player_widget() - player_layout.addWidget(self.player_widget, 1) - - actions_row = QHBoxLayout() - self.add_selected_button = QPushButton(self.language_manager.get_text("add_selected_result"), self) - self.add_selected_button.clicked.connect(self.add_selected_result) - self.add_selected_button.setEnabled(False) - actions_row.addStretch(1) - actions_row.addWidget(self.add_selected_button) - player_layout.addLayout(actions_row) - - self.content_stack.addWidget(self.results_view) - self.content_stack.addWidget(self.player_view) - self.content_stack.setCurrentIndex(self.RESULTS_VIEW_INDEX) - - def search_tracks(self) -> None: - self.youtube_manager.search_tracks(self.search_input.text()) - - def on_search_started(self) -> None: - self.search_button.setEnabled(False) - self.search_button.setText(self.language_manager.get_text("searching")) - - def on_search_results(self, tracks: list) -> None: - self._clear_thumbnail_replies() - self.search_results.clear() - self.current_video = None - self.add_selected_button.setEnabled(False) - self.video_player.stop() - self.content_stack.setCurrentIndex(self.RESULTS_VIEW_INDEX) - - video_tracks = [track for track in tracks if track.get("kind", "video") == "video"] - playlist_tracks = [track for track in tracks if track.get("kind", "") == "playlist"] - - for track in video_tracks: - title = track.get("title", "Untitled") - channel = track.get("channel", "") - duration = track.get("duration", "") - subtitle = f"{self.language_manager.get_text('result_video')} • {duration}" - if channel: - subtitle = f"{subtitle} • {channel}" - item = QListWidgetItem(f"{title}\n{subtitle}") - item.setData(Qt.ItemDataRole.UserRole, track) - self.search_results.addItem(item) - thumbnail_url = track.get("thumbnail_url", "") - if thumbnail_url: - self._request_thumbnail(thumbnail_url, item) - - for track in playlist_tracks: - title = track.get("title", "Untitled playlist") - channel = track.get("channel", "") - item_count = track.get("item_count", "") - subtitle = f"{self.language_manager.get_text('result_playlist')}" - if item_count: - subtitle = f"{subtitle} • {item_count} tracks" - if channel: - subtitle = f"{subtitle} • {channel}" - item = QListWidgetItem(f"{title}\n{subtitle}") - item.setData(Qt.ItemDataRole.UserRole, track) - self.search_results.addItem(item) - thumbnail_url = track.get("thumbnail_url", "") - if thumbnail_url: - self._request_thumbnail(thumbnail_url, item) - - total_results = len(video_tracks) + len(playlist_tracks) - if not total_results: - self.alert_manager.show_error(self.language_manager.get_text("no_search_results"), self) - - self.search_button.setEnabled(True) - self.search_button.setText(self.language_manager.get_text("search_youtube")) - - def add_selected_result(self) -> None: - row = self.search_results.currentRow() - if row < 0: - self.alert_manager.show_error("no_search_result_selected", self) - return - - item_data = self.search_results.item(row).data(Qt.ItemDataRole.UserRole) - if not isinstance(item_data, dict): - return - - if self.youtube_manager.add_search_result(item_data): - self.alert_manager.show_success("track_added", self) - - def on_result_selected(self, current_item: Optional[QListWidgetItem]) -> None: - if current_item is None: - self.current_video = None - self.add_selected_button.setEnabled(False) - return - - item_data = current_item.data(Qt.ItemDataRole.UserRole) - if not isinstance(item_data, dict): - self.current_video = None - self.add_selected_button.setEnabled(False) - return - - self.current_video = item_data - self.add_selected_button.setEnabled(True) - self._load_video_in_player(item_data) - - def go_back_to_results(self) -> None: - self.video_player.stop() - self.content_stack.setCurrentIndex(self.RESULTS_VIEW_INDEX) - - def _load_video_in_player(self, track: dict) -> None: - self.youtube_manager.start_preview(track) - - def _create_player_widget(self) -> QWidget: - video_widget = QVideoWidget(self) - video_widget.setMinimumHeight(320) - return video_widget - - def on_preview_ready(self, stream_url: str, title: str) -> None: - self.video_player.setSource(QUrl(stream_url)) - self.video_player.play() - self.content_stack.setCurrentIndex(self.PLAYER_VIEW_INDEX) - - def on_preview_failed(self, error_key: str) -> None: - self.content_stack.setCurrentIndex(self.RESULTS_VIEW_INDEX) - self.alert_manager.show_error(error_key, self) - - def _request_thumbnail(self, url: str, item: QListWidgetItem) -> None: - request = QNetworkRequest(QUrl(url)) - reply = self.thumbnail_manager.get(request) - self.thumbnail_replies[reply] = item - reply.finished.connect(lambda r=reply: self._on_thumbnail_finished(r)) - - def _on_thumbnail_finished(self, reply: QNetworkReply) -> None: - item = self.thumbnail_replies.pop(reply, None) - if item is None: - reply.deleteLater() - return - - if reply.error() == QNetworkReply.NetworkError.NoError: - image_data = reply.readAll().data() - if image_data: - pixmap = QPixmap() - if pixmap.loadFromData(image_data): - icon = QIcon(pixmap.scaled(120, 68, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)) - item.setIcon(icon) - - reply.deleteLater() - - def _clear_thumbnail_replies(self) -> None: - for reply in list(self.thumbnail_replies.keys()): - reply.abort() - reply.deleteLater() - self.thumbnail_replies.clear() - - - def on_manager_error(self, error_key: str) -> None: - handled_errors = { - "invalid_youtube_url", - "metadata_fetch_failed", - "invalid_search_query", - "search_in_progress", - "search_failed", - "playlist_expand_failed", - } - if error_key not in handled_errors: - return - - self.search_button.setEnabled(True) - self.search_button.setText(self.language_manager.get_text("search_youtube")) - self.alert_manager.show_error(error_key, self) - - def update_language(self) -> None: - self.search_input.setPlaceholderText(self.language_manager.get_text("search_placeholder")) - self.search_button.setText(self.language_manager.get_text("search_youtube")) - self.back_to_results_button.setText(self.language_manager.get_text("back_to_results")) - self.add_selected_button.setText(self.language_manager.get_text("add_selected_result")) - - def closeEvent(self, event) -> None: - self.video_player.stop() - self._clear_thumbnail_replies() - super().closeEvent(event) \ No newline at end of file diff --git a/app/ui/windows/download_list_window.py b/app/ui/windows/download_list_window.py deleted file mode 100644 index 663a768..0000000 --- a/app/ui/windows/download_list_window.py +++ /dev/null @@ -1,563 +0,0 @@ -from pathlib import Path -from typing import Optional - -from PyQt6.QtCore import QSize, Qt, pyqtSignal -from PyQt6.QtGui import QDrag, QIcon -from PyQt6.QtWidgets import ( - QAbstractItemView, - QComboBox, - QDialog, - QDialogButtonBox, - QFileDialog, - QFormLayout, - QHBoxLayout, - QLabel, - QLineEdit, - QListWidget, - QListWidgetItem, - QPushButton, - QSizePolicy, - QStyle, - QVBoxLayout, - QWidget, -) -from app.ui.widgets.loading_bar import LoadingBar - -from app.core.main_manager import MainManager, NotificationType - - -class AlbumDialog(QDialog): - def __init__(self, language_manager, parent: Optional[QWidget] = None) -> None: - super().__init__(parent) - self.language_manager = language_manager - self.setWindowTitle(self.language_manager.get_text("create_album")) - - layout = QVBoxLayout(self) - form = QFormLayout() - - self.name_input = QLineEdit(self) - form.addRow(self.language_manager.get_text("album_name_label"), self.name_input) - - self.artist_input = QLineEdit(self) - form.addRow(self.language_manager.get_text("artist_label"), self.artist_input) - - cover_row = QHBoxLayout() - self.cover_input = QLineEdit(self) - self.cover_input.setReadOnly(True) - cover_row.addWidget(self.cover_input, 1) - - self.browse_button = QPushButton(self.language_manager.get_text("choose_cover"), self) - self.browse_button.clicked.connect(self._choose_cover) - cover_row.addWidget(self.browse_button) - form.addRow(self.language_manager.get_text("cover_label"), cover_row) - - layout.addLayout(form) - - buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, self) - buttons.accepted.connect(self.accept) - buttons.rejected.connect(self.reject) - layout.addWidget(buttons) - - def _choose_cover(self) -> None: - cover_file, _ = QFileDialog.getOpenFileName( - self, - self.language_manager.get_text("choose_cover"), - str(Path.home()), - "Images (*.png *.jpg *.jpeg *.webp)", - ) - if cover_file: - self.cover_input.setText(cover_file) - - -class TrackEditorDialog(QDialog): - def __init__(self, language_manager, track: dict, album_names: list[str], parent: Optional[QWidget] = None) -> None: - super().__init__(parent) - self.language_manager = language_manager - self.setWindowTitle(self.language_manager.get_text("edit_track")) - - layout = QVBoxLayout(self) - form = QFormLayout() - - self.title_input = QLineEdit(str(track.get("mp3_title") or track.get("title", "")), self) - form.addRow(self.language_manager.get_text("mp3_title_label"), self.title_input) - - self.artist_input = QLineEdit(str(track.get("artist", "")), self) - form.addRow(self.language_manager.get_text("artist_label"), self.artist_input) - - self.album_combo = QComboBox(self) - self.album_combo.addItem(self.language_manager.get_text("without_album"), "") - for album_name in album_names: - self.album_combo.addItem(album_name, album_name) - - current_album = str(track.get("album", "")) - album_idx = self.album_combo.findData(current_album) - self.album_combo.setCurrentIndex(album_idx if album_idx >= 0 else 0) - form.addRow(self.language_manager.get_text("assign_album_label"), self.album_combo) - - layout.addLayout(form) - - buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, self) - buttons.accepted.connect(self.accept) - buttons.rejected.connect(self.reject) - layout.addWidget(buttons) - - -class ExplorerGridWidget(QListWidget): - trackMoved = pyqtSignal(int, str) - - def __init__(self, parent: Optional[QWidget] = None) -> None: - super().__init__(parent) - self._dragged_track_index: int = -1 - self.setViewMode(QListWidget.ViewMode.IconMode) - self.setFlow(QListWidget.Flow.LeftToRight) - self.setWrapping(True) - self.setResizeMode(QListWidget.ResizeMode.Adjust) - self.setMovement(QListWidget.Movement.Static) - self.setUniformItemSizes(False) - self.setIconSize(QSize(128, 128)) - self.setGridSize(QSize(180, 160)) - self.setAcceptDrops(True) - self.viewport().setAcceptDrops(True) - - def _item_type(self, item: Optional[QListWidgetItem]) -> str: - if item is None: - return "" - value = item.data(Qt.ItemDataRole.UserRole + 1) - return value if isinstance(value, str) else "" - - def startDrag(self, supportedActions) -> None: - item = self.currentItem() - if self._item_type(item) != "track": - self._dragged_track_index = -1 - return - - track_index = item.data(Qt.ItemDataRole.UserRole + 2) if item else None - self._dragged_track_index = track_index if isinstance(track_index, int) else -1 - if self._dragged_track_index < 0: - return - - mime_data = self.mimeData([item]) - if mime_data is None: - return - mime_data.setText(str(self._dragged_track_index)) - - drag = QDrag(self) - drag.setMimeData(mime_data) - if not item.icon().isNull(): - drag.setPixmap(item.icon().pixmap(self.iconSize())) - drag.exec(Qt.DropAction.MoveAction) - - def _drag_track_index_from_event(self, event) -> int: - mime = event.mimeData() - track_index_text = mime.text().strip() if mime and mime.hasText() else "" - return int(track_index_text) if track_index_text.isdigit() else -1 - - def dragEnterEvent(self, event) -> None: - if self._drag_track_index_from_event(event) >= 0: - event.acceptProposedAction() - return - event.ignore() - - def dragMoveEvent(self, event) -> None: - if self._drag_track_index_from_event(event) < 0: - event.ignore() - return - - target_pos = event.position().toPoint() if hasattr(event, "position") else event.pos() - target_item = self.itemAt(target_pos) - if target_item is None or self._item_type(target_item) != "album": - event.ignore() - return - - event.acceptProposedAction() - - def dropEvent(self, event) -> None: - track_index = self._drag_track_index_from_event(event) - - if track_index < 0: - event.ignore() - return - - target_pos = event.position().toPoint() if hasattr(event, "position") else event.pos() - target_item = self.itemAt(target_pos) - if target_item is None: - event.ignore() - return - - if self._item_type(target_item) != "album": - event.ignore() - return - - album_name_data = target_item.data(Qt.ItemDataRole.UserRole + 3) - album_name = album_name_data if isinstance(album_name_data, str) else "" - - self.trackMoved.emit(track_index, album_name) - event.acceptProposedAction() - - -class DownloadListWindow(QWidget): - def __init__(self, parent: Optional[QWidget] = None) -> None: - super().__init__(parent) - self.main_manager = MainManager.get_instance() - self.language_manager = self.main_manager.get_language_manager() - self.alert_manager = self.main_manager.get_alert_manager() - self.youtube_manager = self.main_manager.get_youtube_manager() - self.metadata_manager = self.main_manager.get_metadata_manager() - - self.observer_manager = self.main_manager.get_observer_manager() - self.observer_manager.subscribe(NotificationType.LANGUAGE, self.update_language) - - self.youtube_manager.list_changed.connect(self.refresh_list) - self.youtube_manager.started.connect(self.on_download_started) - self.youtube_manager.progress_text.connect(self.on_progress_text) - self.youtube_manager.progress_value.connect(self.on_progress_value) - self.youtube_manager.completed.connect(self.on_download_completed) - self.youtube_manager.failed.connect(self.on_download_failed) - self.youtube_manager.queue_finished.connect(self.on_queue_finished) - self.youtube_manager.downloading_state_changed.connect(self.set_downloading_state) - - self.track_data: list[dict] = [] - self.album_profiles: dict[str, dict[str, str]] = {} - self.current_album: str = "" - - self.setup_ui() - self.refresh_list(self.youtube_manager.get_tracks()) - - def setup_ui(self) -> None: - layout = QVBoxLayout(self) - layout.setAlignment(Qt.AlignmentFlag.AlignTop) - layout.setSpacing(12) - layout.setContentsMargins(20, 20, 20, 20) - - self.title_label = QLabel(self.language_manager.get_text("download_list_title"), self) - self.title_label.setWordWrap(True) - self.title_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum) - layout.addWidget(self.title_label) - - tools_row = QHBoxLayout() - self.create_album_button = QPushButton(self.language_manager.get_text("create_album"), self) - self.create_album_button.clicked.connect(self.create_album) - tools_row.addWidget(self.create_album_button) - - self.edit_track_button = QPushButton(self.language_manager.get_text("edit_track"), self) - self.edit_track_button.clicked.connect(self.edit_selected_track) - tools_row.addWidget(self.edit_track_button) - - tools_row.addStretch(1) - layout.addLayout(tools_row) - - nav_row = QHBoxLayout() - self.back_to_root_button = QPushButton(self.language_manager.get_text("back_to_root"), self) - self.back_to_root_button.clicked.connect(self.go_to_root) - nav_row.addWidget(self.back_to_root_button) - - self.path_label = QLabel("", self) - self.path_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) - nav_row.addWidget(self.path_label, 1) - layout.addLayout(nav_row) - - self.explorer_grid = ExplorerGridWidget(self) - self.explorer_grid.setSelectionMode(QAbstractItemView.SelectionMode.MultiSelection) - self.explorer_grid.setDragDropMode(QAbstractItemView.DragDropMode.DragDrop) - self.explorer_grid.setDefaultDropAction(Qt.DropAction.MoveAction) - self.explorer_grid.setDragEnabled(True) - self.explorer_grid.setAcceptDrops(True) - self.explorer_grid.setDropIndicatorShown(True) - self.explorer_grid.setIconSize(QSize(64, 64)) - self.explorer_grid.setGridSize(QSize(110, 100)) - self.explorer_grid.itemDoubleClicked.connect(self.on_grid_item_double_clicked) - self.explorer_grid.trackMoved.connect(self.on_track_dropped) - layout.addWidget(self.explorer_grid, 1) - - destination_layout = QHBoxLayout() - self.folder_input = QLineEdit(self) - self.folder_input.setReadOnly(True) - self.folder_input.setText(str(Path.home() / "Downloads")) - destination_layout.addWidget(self.folder_input, 1) - - self.browse_button = QPushButton(self.language_manager.get_text("choose_folder"), self) - self.browse_button.clicked.connect(self.select_folder) - destination_layout.addWidget(self.browse_button) - layout.addLayout(destination_layout) - - actions_layout = QHBoxLayout() - self.remove_button = QPushButton(self.language_manager.get_text("remove_selected"), self) - self.remove_button.clicked.connect(self.remove_selected) - actions_layout.addWidget(self.remove_button) - - self.clear_button = QPushButton(self.language_manager.get_text("clear_list"), self) - self.clear_button.clicked.connect(self.clear_list) - actions_layout.addWidget(self.clear_button) - layout.addLayout(actions_layout) - - download_layout = QHBoxLayout() - self.download_selected_button = QPushButton(self.language_manager.get_text("download_selected"), self) - self.download_selected_button.clicked.connect(self.download_selected) - download_layout.addWidget(self.download_selected_button) - - self.download_all_button = QPushButton(self.language_manager.get_text("download_all"), self) - self.download_all_button.clicked.connect(self.download_all) - download_layout.addWidget(self.download_all_button) - layout.addLayout(download_layout) - - self.loading_bar = None - - self.status_label = QLabel("", self) - self.status_label.setWordWrap(True) - layout.addWidget(self.status_label) - - def _show_loading_bar(self, label: str): - if self.loading_bar is not None: - self.loading_bar.deleteLater() - self.loading_bar = LoadingBar(label, self) - self.layout().addWidget(self.loading_bar) - self.loading_bar.set_label(label) - self.loading_bar.set_progress(0) - - def refresh_list(self, tracks: list) -> None: - self.track_data = tracks - self._sync_albums_from_tracks() - self._render_grid() - - def _sync_albums_from_tracks(self) -> None: - for track in self.track_data: - album_name = str(track.get("album", "")).strip() - if not album_name: - continue - if album_name not in self.album_profiles: - self.album_profiles[album_name] = { - "artist": str(track.get("artist", "")).strip(), - "cover_path": str(track.get("cover_path", "")).strip(), - } - - def _add_album_item(self, album_name: str) -> None: - profile = self.album_profiles.get(album_name, {}) - cover_path = str(profile.get("cover_path", "")).strip() - folder_icon: QIcon - if cover_path and Path(cover_path).exists(): - folder_icon = QIcon(cover_path) - else: - folder_icon = self.style().standardIcon(QStyle.StandardPixmap.SP_DirIcon) - - if folder_icon.isNull(): - folder_icon = self.style().standardIcon(QStyle.StandardPixmap.SP_DirIcon) - - item = QListWidgetItem(folder_icon, album_name) - item.setData(Qt.ItemDataRole.UserRole + 1, "album") - item.setData(Qt.ItemDataRole.UserRole + 3, album_name) - item.setFlags((item.flags() | Qt.ItemFlag.ItemIsDropEnabled) & ~Qt.ItemFlag.ItemIsDragEnabled) - self.explorer_grid.addItem(item) - - def _add_track_item(self, index: int, track: dict) -> None: - cover_path = str(track.get("cover_path", "")).strip() - if cover_path and Path(cover_path).exists(): - file_icon = QIcon(cover_path) - else: - file_icon = self.style().standardIcon(QStyle.StandardPixmap.SP_FileIcon) - title = str(track.get("mp3_title") or track.get("title") or track.get("url", "")) - item = QListWidgetItem(file_icon, title) - item.setData(Qt.ItemDataRole.UserRole + 1, "track") - item.setData(Qt.ItemDataRole.UserRole + 2, index) - item.setFlags(item.flags() | Qt.ItemFlag.ItemIsDragEnabled | Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled) - self.explorer_grid.addItem(item) - - def _render_grid(self) -> None: - selected_track_index = self._selected_track_index() - self.explorer_grid.blockSignals(True) - self.explorer_grid.clear() - - if not self.current_album: - for album_name in sorted(self.album_profiles.keys()): - self._add_album_item(album_name) - - for index, track in enumerate(self.track_data): - album_name = str(track.get("album", "")).strip() - if album_name: - continue - self._add_track_item(index, track) - - self.path_label.setText(self.language_manager.get_text("root_path")) - self.back_to_root_button.setVisible(False) - else: - for index, track in enumerate(self.track_data): - album_name = str(track.get("album", "")).strip() - if album_name != self.current_album: - continue - self._add_track_item(index, track) - - self.path_label.setText(f"{self.language_manager.get_text('albums_title')} / {self.current_album}") - self.back_to_root_button.setVisible(True) - - if selected_track_index >= 0: - for row in range(self.explorer_grid.count()): - item = self.explorer_grid.item(row) - track_index = item.data(Qt.ItemDataRole.UserRole + 2) - if isinstance(track_index, int) and track_index == selected_track_index: - self.explorer_grid.setCurrentItem(item) - break - - self.explorer_grid.blockSignals(False) - - def _selected_track_index(self) -> int: - item = self.explorer_grid.currentItem() - if item is None: - return -1 - track_index = item.data(Qt.ItemDataRole.UserRole + 2) - return track_index if isinstance(track_index, int) else -1 - - def on_grid_item_double_clicked(self, item: QListWidgetItem) -> None: - item_type = item.data(Qt.ItemDataRole.UserRole + 1) - if item_type == "album": - album_name = item.data(Qt.ItemDataRole.UserRole + 3) - if isinstance(album_name, str): - self.current_album = album_name - self._render_grid() - return - - if item_type == "track": - self.edit_selected_track() - - def go_to_root(self) -> None: - self.current_album = "" - self._render_grid() - - def create_album(self) -> None: - dialog = AlbumDialog(self.language_manager, self) - if dialog.exec() != QDialog.DialogCode.Accepted: - return - - album_name = dialog.name_input.text().strip() - if not album_name: - return - - cover_path = dialog.cover_input.text().strip() - self.album_profiles[album_name] = { - "artist": dialog.artist_input.text().strip(), - "cover_path": cover_path, - } - # UI: demande au manager d'appliquer la cover à tous les morceaux de l'album - self.metadata_manager.apply_album_cover(album_name, cover_path) - self.current_album = "" - self._render_grid() - self.status_label.setText(self.language_manager.get_text("album_saved")) - - def edit_selected_track(self) -> None: - track_index = self._selected_track_index() - if track_index < 0 or track_index >= len(self.track_data): - self.alert_manager.show_error("no_track_selected", self) - return - - track = self.track_data[track_index] - dialog = TrackEditorDialog(self.language_manager, track, sorted(self.album_profiles.keys()), self) - if dialog.exec() != QDialog.DialogCode.Accepted: - return - - album_name = str(dialog.album_combo.currentData() or "") - profile = self.album_profiles.get(album_name, {"artist": "", "cover_path": ""}) - final_artist = dialog.artist_input.text().strip() or profile.get("artist", "") - final_cover = profile.get("cover_path", "") if album_name else str(track.get("cover_path", "")) - - if self.metadata_manager.update_track_metadata( - track_index, - dialog.title_input.text(), - final_artist, - album_name, - final_cover, - ): - self.status_label.setText(self.language_manager.get_text("metadata_saved")) - - def on_track_dropped(self, track_index: int, album_name: str) -> None: - if track_index < 0 or track_index >= len(self.track_data): - return - - profile = self.album_profiles.get(album_name, {"artist": "", "cover_path": ""}) - if self.metadata_manager.update_track_metadata( - track_index, - str(self.track_data[track_index].get("mp3_title") or self.track_data[track_index].get("title", "")), - profile.get("artist", ""), - album_name, - profile.get("cover_path", ""), - ): - self.status_label.setText(self.language_manager.get_text("metadata_saved")) - - def select_folder(self) -> None: - selected_folder = QFileDialog.getExistingDirectory( - self, - self.language_manager.get_text("choose_output_folder"), - self.folder_input.text(), - ) - if selected_folder: - self.folder_input.setText(selected_folder) - - def remove_selected(self) -> None: - track_index = self._selected_track_index() - self.youtube_manager.remove_track(track_index) - - def clear_list(self) -> None: - self.youtube_manager.clear_tracks() - self.status_label.setText(self.language_manager.get_text("list_cleared")) - - def download_selected(self) -> None: - track_index = self._selected_track_index() - self.youtube_manager.download_track_at(track_index, self.folder_input.text().strip()) - - def download_all(self) -> None: - self.youtube_manager.download_all_tracks(self.folder_input.text().strip()) - - def on_download_started(self) -> None: - self._show_loading_bar(self.language_manager.get_text("downloading_audio")) - self.status_label.setText(self.language_manager.get_text("downloading_audio")) - - def on_progress_text(self, text: str) -> None: - if self.loading_bar is not None: - self.loading_bar.set_label(text) - self.status_label.setText(text) - - def on_progress_value(self, value: int) -> None: - if self.loading_bar is not None: - self.loading_bar.set_progress(value) - - def on_download_completed(self, _title: str) -> None: - if self.loading_bar is not None: - self.loading_bar.set_progress(100) - - def on_download_failed(self, error_key: str) -> None: - if self.loading_bar is not None: - self.loading_bar.set_label(self.language_manager.get_text(error_key)) - self.loading_bar.set_progress(0) - self.loading_bar.deleteLater() - self.loading_bar = None - self.status_label.setText(self.language_manager.get_text(error_key)) - self.alert_manager.show_error(error_key, self) - - def on_queue_finished(self) -> None: - if self.loading_bar is not None: - self.loading_bar.set_label(self.language_manager.get_text("playlist_download_complete")) - self.loading_bar.set_progress(100) - self.loading_bar.deleteLater() - self.loading_bar = None - self.status_label.setText(self.language_manager.get_text("playlist_download_complete")) - self.alert_manager.show_success("playlist_download_complete", self) - - def set_downloading_state(self, downloading: bool) -> None: - self.create_album_button.setEnabled(not downloading) - self.edit_track_button.setEnabled(not downloading) - self.browse_button.setEnabled(not downloading) - self.remove_button.setEnabled(not downloading) - self.clear_button.setEnabled(not downloading) - self.download_selected_button.setEnabled(not downloading) - self.download_all_button.setEnabled(not downloading) - self.explorer_grid.setEnabled(not downloading) - self.back_to_root_button.setEnabled(not downloading) - - def update_language(self) -> None: - self.title_label.setText(self.language_manager.get_text("download_list_title")) - self.create_album_button.setText(self.language_manager.get_text("create_album")) - self.edit_track_button.setText(self.language_manager.get_text("edit_track")) - self.browse_button.setText(self.language_manager.get_text("choose_folder")) - self.remove_button.setText(self.language_manager.get_text("remove_selected")) - self.clear_button.setText(self.language_manager.get_text("clear_list")) - self.download_selected_button.setText(self.language_manager.get_text("download_selected")) - self.download_all_button.setText(self.language_manager.get_text("download_all")) - self.back_to_root_button.setText(self.language_manager.get_text("back_to_root")) - self._render_grid() diff --git a/app/ui/windows/editing_window.py b/app/ui/windows/editing_window.py new file mode 100644 index 0000000..873518f --- /dev/null +++ b/app/ui/windows/editing_window.py @@ -0,0 +1,31 @@ +from PyQt6.QtWidgets import QScrollArea, QWidget, QHBoxLayout +from PyQt6.QtCore import Qt +from app.ui.widgets.grid_widget import GridWidget +from app.core.main_manager import MainManager, NotificationType +from typing import Optional + +class EditingWindow(QWidget): + def __init__(self, parent: Optional[QWidget] = None) -> None: + super().__init__(parent) + self.main_manager: MainManager = MainManager.get_instance() + self.language_manager = self.main_manager.get_language_manager() + self.settings_manager = self.main_manager.get_settings_manager() + self.theme_manager = self.main_manager.get_theme_manager() + + self.observer_manager = self.main_manager.get_observer_manager() + self.observer_manager.subscribe(NotificationType.LANGUAGE, self.update_language) + + self.setup_ui() + + def setup_ui(self) -> None: + layout = QHBoxLayout(self) + scroll_area = QScrollArea(self) + scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + scroll_area.setWidgetResizable(True) + grid_widget = GridWidget(parent=self) + scroll_area.setWidget(grid_widget) + layout.addWidget(scroll_area) + + + def update_language(self) -> None: + return \ No newline at end of file diff --git a/app/ui/windows/settings_window.py b/app/ui/windows/settings_window.py index ad9afcd..217297d 100644 --- a/app/ui/windows/settings_window.py +++ b/app/ui/windows/settings_window.py @@ -14,20 +14,6 @@ class SettingsWindow(QWidget): self.observer_manager = self.main_manager.get_observer_manager() self.observer_manager.subscribe(NotificationType.LANGUAGE, self.update_language) - # Type hints for UI elements - self.language_layout: QHBoxLayout - self.languageLabel: QLabel - self.languageCombo: QComboBox - self.theme_layout: QHBoxLayout - self.themeLabel: QLabel - self.themeCombo: QComboBox - self.audio_format_layout: QHBoxLayout - self.audioFormatLabel: QLabel - self.audioFormatCombo: QComboBox - self.audio_quality_layout: QHBoxLayout - self.audioQualityLabel: QLabel - self.audioQualityCombo: QComboBox - self.setup_ui() def setup_ui(self) -> None: @@ -66,36 +52,8 @@ class SettingsWindow(QWidget): self.theme_layout.addWidget(self.themeCombo) layout.addLayout(self.theme_layout) - - layout.addStretch(1) - - self.audio_format_layout = QHBoxLayout() - self.audioFormatLabel = QLabel(self.language_manager.get_text("audio_format_setting"), self) - self.audioFormatLabel.setMinimumWidth(100) - self.audioFormatLabel.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed) - self.audio_format_layout.addWidget(self.audioFormatLabel) - - self.audioFormatCombo = self.createAudioFormatSelector() - self.audioFormatCombo.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) - self.audio_format_layout.addWidget(self.audioFormatCombo) - layout.addLayout(self.audio_format_layout) - - layout.addStretch(1) - - self.audio_quality_layout = QHBoxLayout() - self.audioQualityLabel = QLabel(self.language_manager.get_text("audio_quality_setting"), self) - self.audioQualityLabel.setMinimumWidth(100) - self.audioQualityLabel.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed) - self.audio_quality_layout.addWidget(self.audioQualityLabel) - - self.audioQualityCombo = self.createAudioQualitySelector() - self.audioQualityCombo.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) - self.audio_quality_layout.addWidget(self.audioQualityCombo) - layout.addLayout(self.audio_quality_layout) layout.addStretch(1) - - self._update_audio_quality_enabled_state() def createLanguageSelector(self) -> QComboBox: combo: QComboBox = QComboBox() @@ -130,52 +88,10 @@ class SettingsWindow(QWidget): theme: str = self.themeCombo.itemData(index) self.settings_manager.set_theme(theme) - def createAudioFormatSelector(self) -> QComboBox: - combo: QComboBox = QComboBox() - combo.addItem(self.language_manager.get_text("audio_format_mp3"), "mp3") - combo.addItem(self.language_manager.get_text("audio_format_best"), "best") - - current_index = combo.findData(self.settings_manager.get_audio_format()) - combo.setCurrentIndex(current_index if current_index >= 0 else 0) - combo.currentIndexChanged.connect(self.change_audio_format) - return combo - - def createAudioQualitySelector(self) -> QComboBox: - combo: QComboBox = QComboBox() - combo.addItem("320 kbps", "320") - combo.addItem("192 kbps", "192") - combo.addItem("128 kbps", "128") - - current_index = combo.findData(self.settings_manager.get_audio_quality()) - combo.setCurrentIndex(current_index if current_index >= 0 else 0) - combo.currentIndexChanged.connect(self.change_audio_quality) - return combo - - def change_audio_format(self, index: int) -> None: - audio_format = self.audioFormatCombo.itemData(index) - self.settings_manager.set_audio_format(audio_format) - self._update_audio_quality_enabled_state() - - def change_audio_quality(self, index: int) -> None: - quality = self.audioQualityCombo.itemData(index) - self.settings_manager.set_audio_quality(quality) - - def _update_audio_quality_enabled_state(self) -> None: - self.audioQualityCombo.setEnabled(self.settings_manager.get_audio_format() == "mp3") - def update_language(self) -> None: self.languageLabel.setText(self.language_manager.get_text("language")) self.themeLabel.setText(self.language_manager.get_text("theme")) - self.audioFormatLabel.setText(self.language_manager.get_text("audio_format_setting")) - self.audioQualityLabel.setText(self.language_manager.get_text("audio_quality_setting")) # Mettre à jour les textes dans la combo de thème for i in range(self.themeCombo.count()): - self.themeCombo.setItemText(i, self.language_manager.get_text(self.themeCombo.itemData(i)+ "_theme")) - - current_audio_format = self.audioFormatCombo.currentData() - self.audioFormatCombo.setItemText(0, self.language_manager.get_text("audio_format_mp3")) - self.audioFormatCombo.setItemText(1, self.language_manager.get_text("audio_format_best")) - format_index = self.audioFormatCombo.findData(current_audio_format) - if format_index >= 0: - self.audioFormatCombo.setCurrentIndex(format_index) \ No newline at end of file + self.themeCombo.setItemText(i, self.language_manager.get_text(self.themeCombo.itemData(i)+ "_theme")) \ No newline at end of file diff --git a/data/assets/edit.svg b/data/assets/edit.svg new file mode 100644 index 0000000..c2f870b --- /dev/null +++ b/data/assets/edit.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/data/assets/icon.ico b/data/assets/icon.ico new file mode 100644 index 0000000..2e25305 Binary files /dev/null and b/data/assets/icon.ico differ diff --git a/data/lang/en.json b/data/lang/en.json index 780e294..11979a1 100644 --- a/data/lang/en.json +++ b/data/lang/en.json @@ -62,90 +62,6 @@ "compare_versions": "Compare Versions", "no_license": "No License", "tab_suggestions": "Suggestions", - "tab_download": "Download", - "tab_add_music": "Add", - "tab_download_list": "List", "tab_settings": "Settings", - "tab_licensing": "Licensing", - "download_title": "Download music from YouTube.", - "add_music_title": "Add a YouTube track to the list.", - "download_list_title": "Tracks to download.", - "youtube_url_label": "YouTube URL:", - "youtube_url_placeholder": "Paste YouTube URL here...", - "search_label": "YouTube search:", - "search_placeholder": "Type a title, artist, or keyword...", - "search_youtube": "Search", - "back_to_results": "Back to results", - "searching": "Searching...", - "search_results_found": "Results found: {count}", - "no_search_results": "No results found.", - "result_video": "Video", - "result_playlist": "Playlist", - "add_selected_result": "Add selected result", - "no_search_result_selected": "Select a search result.", - "preview_selected": "Preview", - "stop_preview": "Stop", - "preview_loading": "Loading preview...", - "now_playing": "Now playing: {title}", - "preview_unavailable": "Preview is unavailable for this item.", - "preview_playlist_unavailable": "Playlist preview is not available.", - "preview_in_progress": "A preview is already loading.", - "add_to_list": "Add to list", - "track_added": "Track added to the list.", - "tracks_in_list": "Tracks in list: {count}", - "albums_title": "Albums", - "tracks_title": "Tracks", - "album_filter": "Album:", - "all_albums": "All albums", - "without_album": "Without album", - "album_name_label": "Album name:", - "assign_album_label": "Album folder:", - "save_album": "Save album", - "album_saved": "Album saved.", - "back_to_root": "Back to root", - "root_path": "Root", - "new_album_placeholder": "New album name...", - "create_album": "Create album", - "edit_track": "Edit track", - "mp3_title_label": "MP3 title:", - "artist_label": "Artist:", - "album_label": "Album:", - "cover_label": "Cover:", - "choose_cover": "Choose cover", - "save_track_metadata": "Save metadata", - "metadata_saved": "Metadata saved.", - "remove_selected": "Remove selected", - "clear_list": "Clear list", - "list_cleared": "List cleared.", - "download_selected": "Download selected", - "download_all": "Download all", - "no_track_selected": "Select a track in the list.", - "empty_download_list": "The list is empty.", - "playlist_download_complete": "All downloads are complete.", - "output_folder_label": "Output folder:", - "choose_folder": "Choose", - "choose_output_folder": "Choose output folder", - "download_audio": "Download audio", - "downloading_audio": "Downloading...", - "audio_download_success": "Download completed successfully!", - "audio_download_error": "An error occurred during download.", - "download_in_progress": "A download is already in progress.", - "audio_format_setting": "Audio format:", - "audio_quality_setting": "Quality:", - "audio_format_best": "Original quality (best)", - "audio_format_mp3": "MP3", - "ffmpeg_required": "FFmpeg is required for MP3 conversion. Install FFmpeg and try again.", - "metadata_fetch_failed": "Unable to fetch the video title.", - "invalid_search_query": "Search query must contain at least 2 characters.", - "search_in_progress": "A search is already in progress.", - "search_failed": "YouTube search failed.", - "youtube_player_unavailable": "YouTube player unavailable. Install PyQt6-WebEngine.", - "playlist_expand_failed": "Unable to load the selected playlist.", - "invalid_youtube_url": "Please enter a valid YouTube URL.", - "invalid_output_folder": "Output folder is invalid.", - "add_link_placeholder": "Paste a YouTube link (track or playlist)...", - "add_link_button": "Add by link", - "invalid_link": "Please enter a valid link.", - "link_added": "Link added to the list.", - "playlist_added_count": "Playlist added: {count} tracks." + "tab_licensing": "Licensing" } \ No newline at end of file diff --git a/data/lang/fr.json b/data/lang/fr.json index 06beff3..a985795 100644 --- a/data/lang/fr.json +++ b/data/lang/fr.json @@ -63,90 +63,6 @@ "compare_versions": "Comparer les versions", "no_license": "Pas de licence", "tab_suggestions": "Suggestions", - "tab_download": "Télécharger", - "tab_add_music": "Ajouter", - "tab_download_list": "Liste", "tab_settings": "Paramètres", - "tab_licensing": "Licence", - "download_title": "Téléchargez une musique depuis YouTube.", - "add_music_title": "Ajoutez une musique YouTube à la liste.", - "download_list_title": "Liste des musiques à télécharger.", - "youtube_url_label": "Lien YouTube :", - "youtube_url_placeholder": "Collez ici l'URL YouTube...", - "search_label": "Recherche YouTube :", - "search_placeholder": "Tapez un titre, artiste ou mot-clé...", - "search_youtube": "Rechercher", - "back_to_results": "Retour aux résultats", - "searching": "Recherche en cours...", - "search_results_found": "Résultats trouvés : {count}", - "no_search_results": "Aucun résultat trouvé.", - "result_video": "Vidéo", - "result_playlist": "Playlist", - "add_selected_result": "Ajouter le résultat sélectionné", - "no_search_result_selected": "Sélectionnez un résultat de recherche.", - "preview_selected": "Préécouter", - "stop_preview": "Stop", - "preview_loading": "Chargement de l'aperçu...", - "now_playing": "Lecture : {title}", - "preview_unavailable": "Préécoute indisponible pour cet élément.", - "preview_playlist_unavailable": "La préécoute des playlists n'est pas disponible.", - "preview_in_progress": "Une préécoute est déjà en cours de chargement.", - "add_to_list": "Ajouter à la liste", - "track_added": "Musique ajoutée à la liste.", - "tracks_in_list": "Musiques dans la liste : {count}", - "albums_title": "Albums", - "tracks_title": "Musiques", - "album_filter": "Album :", - "all_albums": "Tous les albums", - "without_album": "Sans album", - "album_name_label": "Nom de l'album :", - "assign_album_label": "Dossier album :", - "save_album": "Enregistrer album", - "album_saved": "Album enregistré.", - "back_to_root": "Retour racine", - "root_path": "Racine", - "new_album_placeholder": "Nouveau nom d'album...", - "create_album": "Créer album", - "edit_track": "Éditer musique", - "mp3_title_label": "Titre MP3 :", - "artist_label": "Artiste :", - "album_label": "Album :", - "cover_label": "Cover :", - "choose_cover": "Choisir cover", - "save_track_metadata": "Enregistrer métadonnées", - "metadata_saved": "Métadonnées enregistrées.", - "remove_selected": "Supprimer la sélection", - "clear_list": "Vider la liste", - "list_cleared": "Liste vidée.", - "download_selected": "Télécharger la sélection", - "download_all": "Tout télécharger", - "no_track_selected": "Sélectionnez une musique dans la liste.", - "empty_download_list": "La liste est vide.", - "playlist_download_complete": "Tous les téléchargements sont terminés.", - "output_folder_label": "Dossier de destination :", - "choose_folder": "Choisir", - "choose_output_folder": "Choisissez le dossier de destination", - "download_audio": "Télécharger l'audio", - "downloading_audio": "Téléchargement...", - "audio_download_success": "Téléchargement terminé avec succès !", - "audio_download_error": "Une erreur est survenue pendant le téléchargement.", - "download_in_progress": "Un téléchargement est déjà en cours.", - "audio_format_setting": "Format audio :", - "audio_quality_setting": "Qualité :", - "audio_format_best": "Qualité d'origine (meilleure)", - "audio_format_mp3": "MP3", - "ffmpeg_required": "FFmpeg est requis pour convertir en MP3. Installez FFmpeg puis réessayez.", - "metadata_fetch_failed": "Impossible de récupérer le titre de la vidéo.", - "invalid_search_query": "La recherche doit contenir au moins 2 caractères.", - "search_in_progress": "Une recherche est déjà en cours.", - "search_failed": "La recherche YouTube a échoué.", - "youtube_player_unavailable": "Lecteur YouTube indisponible. Installez PyQt6-WebEngine.", - "playlist_expand_failed": "Impossible de charger la playlist sélectionnée.", - "invalid_youtube_url": "Veuillez entrer une URL YouTube valide.", - "invalid_output_folder": "Le dossier de destination est invalide.", - "add_link_placeholder": "Collez un lien YouTube (musique ou playlist)...", - "add_link_button": "Ajouter par lien", - "invalid_link": "Veuillez entrer un lien valide.", - "link_added": "Lien ajouté à la liste.", - "playlist_added_count": "Playlist ajoutée : {count} musiques." + "tab_licensing": "Licence" } \ No newline at end of file diff --git a/data/others/defaults_settings.json b/data/others/defaults_settings.json index a64cbf8..f963074 100644 --- a/data/others/defaults_settings.json +++ b/data/others/defaults_settings.json @@ -2,7 +2,5 @@ "theme": "dark", "lang": "fr", "window_size": {"width": 1000, "height": 600}, - "maximized": true, - "audio_format": "mp3", - "audio_quality": "320" + "maximized": true } \ No newline at end of file diff --git a/main.py b/main.py index 924884c..7d9f402 100644 --- a/main.py +++ b/main.py @@ -1,6 +1,5 @@ import sys import app.utils.paths as paths -from PyQt6.QtCore import Qt from PyQt6.QtWidgets import QApplication from PyQt6.QtGui import QIcon from app.ui.main_window import MainWindow @@ -24,96 +23,78 @@ def preload_application(progress_callback, splash=None): try: main_manager = MainManager.get_instance() - language_manager = main_manager.get_language_manager() update_manager = main_manager.get_update_manager() license_manager = main_manager.get_license_manager() settings_manager = main_manager.get_settings_manager() + # Vérifier la licence uniquement si le système est activé if settings_manager.get_config("enable_licensing"): progress_callback(language_manager.get_text("verifying_license")) license_manager.verify_license() progress_callback(language_manager.get_text("checking_updates")) + if update_manager.check_for_update(splash, splash): return False progress_callback(language_manager.get_text("initializing")) + preloaded_window = MainWindow() - + progress_callback(language_manager.get_text("loading_complete")) - + return True - except Exception: + except Exception as e: + print(f"Error during preload: {e}") return False def main() -> int: global preloaded_window - try: - QApplication.setAttribute(Qt.ApplicationAttribute.AA_ShareOpenGLContexts, True) - except Exception: - return 0 + + main_manager: MainManager = MainManager.get_instance() + theme_manager = main_manager.get_theme_manager() + settings_manager = main_manager.get_settings_manager() - try: - main_manager: MainManager = MainManager.get_instance() - - theme_manager = main_manager.get_theme_manager() - settings_manager = main_manager.get_settings_manager() - except Exception as e: - print(f"Error occurred while initializing managers: {e}") - return 0 - - try: - app: QApplication = QApplication(sys.argv) - except Exception: - return 0 - - try: - app.setStyleSheet(theme_manager.get_sheet()) - app.setApplicationName(settings_manager.get_config("app_name")) - app.setWindowIcon(QIcon(paths.get_asset_path("icon"))) - except Exception: - return 0 + app: QApplication = QApplication(sys.argv) + app.setStyleSheet(theme_manager.get_sheet()) + app.setApplicationName(settings_manager.get_config("app_name")) + app.setWindowIcon(QIcon(paths.get_asset_path("icon"))) splash_image_path = paths.get_asset_path(settings_manager.get_config("splash_image")) use_splash = splash_image_path and paths.Path(splash_image_path).exists() - + if use_splash: - try: - splash = SplashScreen(preload_function=lambda callback: preload_application(callback, splash)) - splash.show_splash() - except Exception as e: - return 0 - + splash = SplashScreen(preload_function=lambda callback: preload_application(callback, splash)) + splash.show_splash() + def on_splash_finished(success): global preloaded_window if not success: app.quit() return - try: - if preloaded_window: - preloaded_window.show() - else: - window = MainWindow() - window.show() - except Exception: - app.quit() + + if preloaded_window: + preloaded_window.show() + else: + window = MainWindow() + window.show() splash.finished.connect(on_splash_finished) - return app.exec() else: + def dummy_progress(text): pass + success = preload_application(dummy_progress) if not success: return 0 - try: - window = preloaded_window if preloaded_window else MainWindow() - window.show() - except Exception: - return 0 - return app.exec() + + window = preloaded_window if preloaded_window else MainWindow() + window.show() + + return app.exec() if __name__ == "__main__": sys.exit(main()) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 60c1aed..390d828 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,4 @@ PyQt6 -PyQt6-WebEngine pyinstaller python-dotenv -requests -yt-dlp -mutagen -pillow \ No newline at end of file +requests \ No newline at end of file diff --git a/tools/build.command b/tools/build.command new file mode 100644 index 0000000..7d73185 --- /dev/null +++ b/tools/build.command @@ -0,0 +1,88 @@ +#!/bin/bash + +set -e +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PARENT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + +CONFIG_FILE="$PARENT_DIR/config.json" +ICON_FILE="$PARENT_DIR/data/assets/icon.png" +ENV_FILE="$PARENT_DIR/.env" + +# === Check .env === +if [ ! -f "$ENV_FILE" ]; then + echo "[ERROR] .env file not found. Please copy .env.example to .env and configure it." + exit 1 +fi + +# === Extract values from config.json === +ICON_PATH=$(python3 -c "import json; print(json.load(open('$CONFIG_FILE'))['icon_path'])") +APP_NAME=$(python3 -c "import json; print(json.load(open('$CONFIG_FILE'))['app_name'])") +ARCHITECTURE=$(python3 -c "import json; print(json.load(open('$CONFIG_FILE'))['architecture'])") + +# === Extract PYTHON_PATH from .env === +SYSTEM_PYTHON=$(grep '^PYTHON_PATH=' "$ENV_FILE" | cut -d '=' -f2-) + +VENV_PATH="$PARENT_DIR/MACenv_$ARCHITECTURE" +APP_FILE="$APP_NAME.app" +PYTHON_IN_VENV="$VENV_PATH/bin/python" +BUILD_DIR="$PARENT_DIR/build" +ZIP_FILE="$BUILD_DIR/$APP_NAME.zip" + +# === Verify Python existence === +if [ ! -x "$SYSTEM_PYTHON" ]; then + echo "[ERROR] Python not found at: $SYSTEM_PYTHON" + exit 1 +fi + +# === Check virtual environment === +if [ ! -f "$VENV_PATH/bin/activate" ]; then + echo "[INFO] Virtual environment not found. Creating..." + "$SYSTEM_PYTHON" -m venv "$VENV_PATH" + "$PYTHON_IN_VENV" -m pip install --upgrade pip + "$PYTHON_IN_VENV" -m pip install -r "$PARENT_DIR/requirements.txt" +else + echo "[INFO] Virtual environment found." +fi + +# === Run PyInstaller === +"$PYTHON_IN_VENV" -m PyInstaller \ + --distpath "$BUILD_DIR" \ + --workpath "$BUILD_DIR/dist" \ + --clean \ + "$PARENT_DIR/BUILD.spec" + +# === Clean build cache === +rm -rf "$BUILD_DIR/dist" + +# === Create ZIP === +echo "[INFO] Creating ZIP archive..." + +TEMP_ZIP_DIR="$BUILD_DIR/temp_zip" + +# Remove old temp dir +rm -rf "$TEMP_ZIP_DIR" +mkdir -p "$TEMP_ZIP_DIR" + +# Move compiled app +mv "$BUILD_DIR/$APP_FILE" "$TEMP_ZIP_DIR/" + +# Copy config.json +cp "$CONFIG_FILE" "$TEMP_ZIP_DIR/" + +# Copy icon.png +cp "$ICON_FILE" "$TEMP_ZIP_DIR/" + +# Copy data/lang +cp -R "$PARENT_DIR/data/lang" "$TEMP_ZIP_DIR/lang" + +# Remove old ZIP +rm -f "$ZIP_FILE" + +# Create ZIP +cd "$BUILD_DIR" +zip -r "$ZIP_FILE" "temp_zip" >/dev/null + +# Remove temp folder +rm -rf "$TEMP_ZIP_DIR" + +echo "[INFO] ZIP created at: $ZIP_FILE" \ No newline at end of file diff --git a/tools/open.command b/tools/open.command new file mode 100644 index 0000000..e69de29