This commit is contained in:
Louis Mazin 2026-03-18 16:35:14 +01:00
parent 00da2555ed
commit 9cbe5d4de0
14 changed files with 2027 additions and 18 deletions

View File

@ -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

View File

@ -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:
@ -49,3 +51,6 @@ class MainManager:
def get_license_manager(self) -> LicenseManager:
return self.license_manager
def get_download_manager(self) -> DownloadManager:
return self.download_manager

View File

@ -179,3 +179,42 @@ class SettingsManager:
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}")

View File

@ -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"))
self.side_menu.update_button_text(self.licensing_tab_index, self.language_manager.get_text("tab_licensing"))

View File

@ -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:

View File

@ -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)

View File

@ -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()

View File

@ -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()
@ -63,6 +69,34 @@ class SettingsWindow(QWidget):
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()
# Ajouter toutes les langues disponibles
@ -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"))
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)

View File

@ -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"

View File

@ -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."
}

View File

@ -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."
}

View File

@ -2,5 +2,7 @@
"theme": "dark",
"lang": "fr",
"window_size": {"width": 1000, "height": 600},
"maximized": true
"maximized": true,
"audio_format": "mp3",
"audio_quality": "320"
}

View File

@ -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
@ -53,6 +54,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()
settings_manager = main_manager.get_settings_manager()
@ -94,7 +97,9 @@ def main() -> int:
window = preloaded_window if preloaded_window else MainWindow()
window.show()
return app.exec()
if __name__ == "__main__":
try:
sys.exit(main())
except KeyboardInterrupt:
sys.exit(0)

View File

@ -1,4 +1,7 @@
PyQt6
PyQt6-WebEngine
pyinstaller
python-dotenv
requests
yt-dlp
mutagen