diff --git a/app/core/download_manager.py b/app/core/download_manager.py new file mode 100644 index 0000000..5d556d0 --- /dev/null +++ b/app/core/download_manager.py @@ -0,0 +1,729 @@ +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: + metadata = self.track_metadata + 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.delall("APIC") + if track_cover: + cover_file = Path(track_cover) + if cover_file.exists() and cover_file.is_file(): + mime_type = "image/jpeg" + cover_data = None + if cover_file.suffix.lower() in (".png", ".webp"): + try: + from PIL import Image + import io + img = Image.open(cover_file) + buf = io.BytesIO() + img.convert("RGB").save(buf, format="JPEG") + cover_data = buf.getvalue() + mime_type = "image/jpeg" + except Exception: + with open(cover_file, "rb") as stream: + cover_data = stream.read() + mime_type = "image/png" if cover_file.suffix.lower() == ".png" else "image/webp" + else: + with open(cover_file, "rb") as stream: + cover_data = stream.read() + if cover_data: + tags.add(APIC(encoding=3, mime=mime_type, type=3, desc="Cover", data=cover_data)) + + tags.save(str(file_path), v2_version=4) + + renamed_file = self._rename_output_file(file_path, track_title or fallback_title, track_artist) + if renamed_file is not None: + self.progress_text.emit(renamed_file.stem) + + 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) -> Optional[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) + + if not base_name: + return None + + 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 None + + +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 "" + duration = self._format_duration(entry.get("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 DownloadManager(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] + clean_mp3_title = mp3_title.strip() or track.get("title", "") + clean_artist = artist.strip() + clean_album = album.strip() + clean_cover = cover_path.strip() + + # Update in-memory metadata + track["mp3_title"] = clean_mp3_title + track["artist"] = clean_artist + track["album"] = clean_album + track["cover_path"] = clean_cover + + # Try to find the file path (assume output dir is known, search for file) + # Use the current filename or try to reconstruct it + output_dir = self.settings_manager.get_config("output_dir") + if not output_dir: + output_dir = "." + output_dir = Path(output_dir) + # Try to find the file by matching artist/title or fallback to most recent + found_file = None + for f in output_dir.glob("*.mp3"): + # Try to match by old artist/title + if f.stem == f"{track.get('artist', '')} - {track.get('mp3_title', '')}": + found_file = f + break + if not found_file: + # fallback: most recent + mp3_files = sorted(output_dir.glob("*.mp3"), key=lambda file_path: file_path.stat().st_mtime, reverse=True) + if mp3_files: + found_file = mp3_files[0] + + if found_file and found_file.exists(): + # Update ID3 tags + try: + id3_delete(str(found_file)) + except Exception: + pass + tags = ID3() + tags.delall("TIT2") + tags.add(TIT2(encoding=3, text=clean_mp3_title or track.get("title", ""))) + tags.delall("TPE1") + if clean_artist: + tags.add(TPE1(encoding=3, text=clean_artist)) + tags.delall("TALB") + if clean_album: + tags.add(TALB(encoding=3, text=clean_album)) + tags.delall("APIC") + if clean_cover: + cover_file = Path(clean_cover) + if cover_file.exists() and cover_file.is_file(): + mime_type = "image/jpeg" + if cover_file.suffix.lower() == ".png": + mime_type = "image/png" + elif cover_file.suffix.lower() == ".webp": + mime_type = "image/webp" + with open(cover_file, "rb") as stream: + cover_data = stream.read() + if cover_data: + tags.add(APIC(encoding=3, mime=mime_type, type=3, desc="Cover", data=cover_data)) + tags.save(str(found_file), v2_version=4) + + # Always rename file to 'artist - title.mp3' + safe_title = self._sanitize_filename_part(clean_mp3_title) + safe_artist = self._sanitize_filename_part(clean_artist) + if safe_artist and safe_title: + base_name = f"{safe_artist} - {safe_title}" + else: + base_name = safe_title or self._sanitize_filename_part(found_file.stem) + target_path = found_file.with_name(f"{base_name}{found_file.suffix}") + if target_path != found_file: + counter = 2 + while target_path.exists(): + target_path = found_file.with_name(f"{base_name} ({counter}){found_file.suffix}") + counter += 1 + try: + found_file.rename(target_path) + except Exception: + pass + + 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/core/main_manager.py b/app/core/main_manager.py index b6f9077..643a4d3 100644 --- a/app/core/main_manager.py +++ b/app/core/main_manager.py @@ -5,6 +5,7 @@ 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.download_manager import DownloadManager from typing import Optional @@ -23,6 +24,7 @@ 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.download_manager: DownloadManager = DownloadManager(self.settings_manager) @classmethod def get_instance(cls) -> 'MainManager': if cls._instance is None: @@ -48,4 +50,7 @@ class MainManager: return self.update_manager def get_license_manager(self) -> LicenseManager: - return self.license_manager \ No newline at end of file + return self.license_manager + + def get_download_manager(self) -> DownloadManager: + return self.download_manager \ No newline at end of file diff --git a/app/core/settings_manager.py b/app/core/settings_manager.py index c9e138b..e035633 100644 --- a/app/core/settings_manager.py +++ b/app/core/settings_manager.py @@ -178,4 +178,43 @@ class SettingsManager: try: self.settings.setValue("maximized", maximized) except Exception as e: - logger.error(f"Error setting maximized state: {e}") \ No newline at end of file + 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 diff --git a/app/ui/main_window.py b/app/ui/main_window.py index cef1341..a10dd08 100644 --- a/app/ui/main_window.py +++ b/app/ui/main_window.py @@ -6,6 +6,8 @@ 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 import app.utils.paths as paths, shutil from typing import Optional @@ -249,16 +251,22 @@ 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.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.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.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.settings_window = SettingsWindow(self) - 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.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) # 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.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.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.setCentralWidget(self.side_menu) @@ -271,7 +279,9 @@ class MainWindow(QMainWindow): def update_language(self) -> None: # Mettre à jour les textes des onglets - 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")) + 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")) if self.settings_manager.get_config("enable_licensing"): - self.side_menu.update_button_text(2, self.language_manager.get_text("tab_licensing")) \ No newline at end of file + self.side_menu.update_button_text(self.licensing_tab_index, self.language_manager.get_text("tab_licensing")) \ No newline at end of file diff --git a/app/ui/widgets/tabs_widget.py b/app/ui/widgets/tabs_widget.py index 6e2ce7a..1ad226b 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 : QPushButton, event): + def eventFilter(self, obj, event): """Handle hover events for buttons""" if obj in self.buttons: if event.type() == event.Type.Enter: diff --git a/app/ui/windows/add_music_window.py b/app/ui/windows/add_music_window.py new file mode 100644 index 0000000..022080d --- /dev/null +++ b/app/ui/windows/add_music_window.py @@ -0,0 +1,408 @@ +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): + super().__init__() + 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.download_manager = self.main_manager.get_download_manager() + + self.observer_manager = self.main_manager.get_observer_manager() + self.observer_manager.subscribe(NotificationType.LANGUAGE, self.update_language) + + self.download_manager.list_changed.connect(self.on_list_changed) + self.download_manager.failed.connect(self.on_manager_error) + self.download_manager.search_started.connect(self.on_search_started) + self.download_manager.search_results.connect(self.on_search_results) + self.download_manager.preview_started.connect(self.on_preview_started) + self.download_manager.preview_ready.connect(self.on_preview_ready) + self.download_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.count_label = QLabel("", self) + self.status_label = QLabel("", self) + self.status_label.setWordWrap(True) + 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) + + self.layout().addWidget(self.count_label) + self.layout().addWidget(self.status_label) + if self.player_widget and isinstance(self.player_widget, QVideoWidget): + self.video_player.setVideoOutput(self.player_widget) + self.on_list_changed(self.download_manager.get_tracks()) + + 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.status_label.setText(self.language_manager.get_text("invalid_link")) + return + if "list=" in url: + self._show_loading_bar(self.language_manager.get_text("downloading_audio")) + self.playlist_thread = self.PlaylistAddThread(self.download_manager, url) + 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 self.download_manager.add_track(url): + self.status_label.setText(self.language_manager.get_text("link_added")) + else: + self.status_label.setText(self.language_manager.get_text("invalid_link")) + + 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.status_label.setText(self.language_manager.get_text("playlist_added_count").replace("{count}", str(count))) + + 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.status_label.setText(self.language_manager.get_text(error_key)) + + 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) + + self.layout().addWidget(self.count_label) + self.layout().addWidget(self.status_label) + + def search_tracks(self) -> None: + self.download_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")) + self.status_label.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"] + 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) + + if video_tracks: + self.status_label.setText(self.language_manager.get_text("search_results_found").replace("{count}", str(len(video_tracks)))) + else: + self.status_label.setText(self.language_manager.get_text("no_search_results")) + + 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.download_manager.add_search_result(item_data): + self.status_label.setText(self.language_manager.get_text("track_added")) + + 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.download_manager.start_preview(track) + + def _create_player_widget(self) -> QWidget: + video_widget = QVideoWidget(self) + video_widget.setMinimumHeight(320) + return video_widget + + def on_preview_started(self) -> None: + self.status_label.setText(self.language_manager.get_text("preview_loading")) + + 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) + self.status_label.setText(self.language_manager.get_text("now_playing").replace("{title}", title)) + + def on_preview_failed(self, error_key: str) -> None: + self.content_stack.setCurrentIndex(self.RESULTS_VIEW_INDEX) + self.status_label.setText(self.language_manager.get_text(error_key)) + + 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_list_changed(self, tracks: list) -> None: + text = self.language_manager.get_text("tracks_in_list").replace("{count}", str(len(tracks))) + self.count_label.setText(text) + + 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.status_label.setText(self.language_manager.get_text(error_key)) + self.alert_manager.show_error(error_key, self) + + def update_language(self) -> None: + self.title_label.setText(self.language_manager.get_text("add_music_title")) + 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")) + self.on_list_changed(self.download_manager.get_tracks()) + + 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 new file mode 100644 index 0000000..ea8c95a --- /dev/null +++ b/app/ui/windows/download_list_window.py @@ -0,0 +1,562 @@ +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.download_manager = self.main_manager.get_download_manager() + + self.observer_manager = self.main_manager.get_observer_manager() + self.observer_manager.subscribe(NotificationType.LANGUAGE, self.update_language) + + self.download_manager.list_changed.connect(self.refresh_list) + self.download_manager.started.connect(self.on_download_started) + self.download_manager.progress_text.connect(self.on_progress_text) + self.download_manager.progress_value.connect(self.on_progress_value) + self.download_manager.completed.connect(self.on_download_completed) + self.download_manager.failed.connect(self.on_download_failed) + self.download_manager.queue_finished.connect(self.on_queue_finished) + self.download_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.download_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.download_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.download_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.download_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.download_manager.remove_track(track_index) + + def clear_list(self) -> None: + self.download_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.download_manager.download_track_at(track_index, self.folder_input.text().strip()) + + def download_all(self) -> None: + self.download_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/settings_window.py b/app/ui/windows/settings_window.py index 34d4302..ad9afcd 100644 --- a/app/ui/windows/settings_window.py +++ b/app/ui/windows/settings_window.py @@ -21,6 +21,12 @@ class SettingsWindow(QWidget): 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() @@ -60,8 +66,36 @@ 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() @@ -96,10 +130,52 @@ 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")) \ No newline at end of file + 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 diff --git a/config.json b/config.json index 95719dc..39bb785 100644 --- a/config.json +++ b/config.json @@ -7,7 +7,7 @@ "splash_image": "splash", "main_script": "main.py", "git_repo": "https://gitea.louismazin.ovh/LouisMazin/PythonApplicationTemplate", - "enable_licensing": true, + "enable_licensing": false, "features_by_license": { "basic": [ "support" diff --git a/data/lang/en.json b/data/lang/en.json index 11979a1..a22454c 100644 --- a/data/lang/en.json +++ b/data/lang/en.json @@ -62,6 +62,91 @@ "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" + "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.", + "playlist_added_count": "Playlist added: {count} tracks.", + "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." } \ No newline at end of file diff --git a/data/lang/fr.json b/data/lang/fr.json index a985795..cd5eb07 100644 --- a/data/lang/fr.json +++ b/data/lang/fr.json @@ -63,6 +63,91 @@ "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" + "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.", + "playlist_added_count": "Playlist ajoutée : {count} musiques.", + "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." } \ No newline at end of file diff --git a/data/others/defaults_settings.json b/data/others/defaults_settings.json index f963074..a64cbf8 100644 --- a/data/others/defaults_settings.json +++ b/data/others/defaults_settings.json @@ -2,5 +2,7 @@ "theme": "dark", "lang": "fr", "window_size": {"width": 1000, "height": 600}, - "maximized": true + "maximized": true, + "audio_format": "mp3", + "audio_quality": "320" } \ No newline at end of file diff --git a/main.py b/main.py index 7d9f402..82d60b0 100644 --- a/main.py +++ b/main.py @@ -1,5 +1,6 @@ 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 @@ -52,6 +53,8 @@ def preload_application(progress_callback, splash=None): def main() -> int: global preloaded_window + + QApplication.setAttribute(Qt.ApplicationAttribute.AA_ShareOpenGLContexts, True) main_manager: MainManager = MainManager.get_instance() theme_manager = main_manager.get_theme_manager() @@ -93,8 +96,10 @@ def main() -> int: 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 + try: + sys.exit(main()) + except KeyboardInterrupt: + sys.exit(0) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 390d828..039ee52 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,7 @@ PyQt6 +PyQt6-WebEngine pyinstaller python-dotenv -requests \ No newline at end of file +requests +yt-dlp +mutagen \ No newline at end of file