generated from LouisMazin/PythonApplicationTemplate
v1
This commit is contained in:
parent
00da2555ed
commit
9cbe5d4de0
729
app/core/download_manager.py
Normal file
729
app/core/download_manager.py
Normal 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
|
||||||
@ -5,6 +5,7 @@ from app.core.settings_manager import SettingsManager
|
|||||||
from app.core.alert_manager import AlertManager
|
from app.core.alert_manager import AlertManager
|
||||||
from app.core.update_manager import UpdateManager
|
from app.core.update_manager import UpdateManager
|
||||||
from app.core.license_manager import LicenseManager
|
from app.core.license_manager import LicenseManager
|
||||||
|
from app.core.download_manager import DownloadManager
|
||||||
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
@ -23,6 +24,7 @@ class MainManager:
|
|||||||
self.alert_manager: AlertManager = AlertManager(self.language_manager, self.theme_manager)
|
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.update_manager: UpdateManager = UpdateManager(self.settings_manager, self.language_manager, self.alert_manager)
|
||||||
self.license_manager: LicenseManager = LicenseManager(self.settings_manager)
|
self.license_manager: LicenseManager = LicenseManager(self.settings_manager)
|
||||||
|
self.download_manager: DownloadManager = DownloadManager(self.settings_manager)
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_instance(cls) -> 'MainManager':
|
def get_instance(cls) -> 'MainManager':
|
||||||
if cls._instance is None:
|
if cls._instance is None:
|
||||||
@ -48,4 +50,7 @@ class MainManager:
|
|||||||
return self.update_manager
|
return self.update_manager
|
||||||
|
|
||||||
def get_license_manager(self) -> LicenseManager:
|
def get_license_manager(self) -> LicenseManager:
|
||||||
return self.license_manager
|
return self.license_manager
|
||||||
|
|
||||||
|
def get_download_manager(self) -> DownloadManager:
|
||||||
|
return self.download_manager
|
||||||
@ -178,4 +178,43 @@ class SettingsManager:
|
|||||||
try:
|
try:
|
||||||
self.settings.setValue("maximized", maximized)
|
self.settings.setValue("maximized", maximized)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error setting maximized state: {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}")
|
||||||
@ -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.settings_window import SettingsWindow
|
||||||
from app.ui.windows.suggestion_window import SuggestionWindow
|
from app.ui.windows.suggestion_window import SuggestionWindow
|
||||||
from app.ui.windows.activation_window import ActivationWindow
|
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
|
import app.utils.paths as paths, shutil
|
||||||
from typing import Optional
|
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.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.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.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é
|
# Ajouter la tab d'activation uniquement si le système de licence est activé
|
||||||
if self.settings_manager.get_config("enable_licensing"):
|
if self.settings_manager.get_config("enable_licensing"):
|
||||||
self.activation_window = ActivationWindow(self)
|
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)
|
self.setCentralWidget(self.side_menu)
|
||||||
|
|
||||||
@ -271,7 +279,9 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
def update_language(self) -> None:
|
def update_language(self) -> None:
|
||||||
# Mettre à jour les textes des onglets
|
# 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(self.suggestion_tab_index, 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.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"):
|
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"))
|
||||||
@ -369,7 +369,7 @@ class TabsWidget(QWidget):
|
|||||||
# Store reference for resize updates
|
# Store reference for resize updates
|
||||||
self._square_buttons.append(button)
|
self._square_buttons.append(button)
|
||||||
|
|
||||||
def eventFilter(self, obj : QPushButton, event):
|
def eventFilter(self, obj, event):
|
||||||
"""Handle hover events for buttons"""
|
"""Handle hover events for buttons"""
|
||||||
if obj in self.buttons:
|
if obj in self.buttons:
|
||||||
if event.type() == event.Type.Enter:
|
if event.type() == event.Type.Enter:
|
||||||
|
|||||||
408
app/ui/windows/add_music_window.py
Normal file
408
app/ui/windows/add_music_window.py
Normal 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)
|
||||||
562
app/ui/windows/download_list_window.py
Normal file
562
app/ui/windows/download_list_window.py
Normal 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()
|
||||||
@ -21,6 +21,12 @@ class SettingsWindow(QWidget):
|
|||||||
self.theme_layout: QHBoxLayout
|
self.theme_layout: QHBoxLayout
|
||||||
self.themeLabel: QLabel
|
self.themeLabel: QLabel
|
||||||
self.themeCombo: QComboBox
|
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()
|
self.setup_ui()
|
||||||
|
|
||||||
@ -60,8 +66,36 @@ class SettingsWindow(QWidget):
|
|||||||
self.theme_layout.addWidget(self.themeCombo)
|
self.theme_layout.addWidget(self.themeCombo)
|
||||||
|
|
||||||
layout.addLayout(self.theme_layout)
|
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)
|
layout.addStretch(1)
|
||||||
|
|
||||||
|
self._update_audio_quality_enabled_state()
|
||||||
|
|
||||||
def createLanguageSelector(self) -> QComboBox:
|
def createLanguageSelector(self) -> QComboBox:
|
||||||
combo: QComboBox = QComboBox()
|
combo: QComboBox = QComboBox()
|
||||||
@ -96,10 +130,52 @@ class SettingsWindow(QWidget):
|
|||||||
theme: str = self.themeCombo.itemData(index)
|
theme: str = self.themeCombo.itemData(index)
|
||||||
self.settings_manager.set_theme(theme)
|
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:
|
def update_language(self) -> None:
|
||||||
self.languageLabel.setText(self.language_manager.get_text("language"))
|
self.languageLabel.setText(self.language_manager.get_text("language"))
|
||||||
self.themeLabel.setText(self.language_manager.get_text("theme"))
|
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
|
# Mettre à jour les textes dans la combo de thème
|
||||||
for i in range(self.themeCombo.count()):
|
for i in range(self.themeCombo.count()):
|
||||||
self.themeCombo.setItemText(i, self.language_manager.get_text(self.themeCombo.itemData(i)+ "_theme"))
|
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)
|
||||||
@ -7,7 +7,7 @@
|
|||||||
"splash_image": "splash",
|
"splash_image": "splash",
|
||||||
"main_script": "main.py",
|
"main_script": "main.py",
|
||||||
"git_repo": "https://gitea.louismazin.ovh/LouisMazin/PythonApplicationTemplate",
|
"git_repo": "https://gitea.louismazin.ovh/LouisMazin/PythonApplicationTemplate",
|
||||||
"enable_licensing": true,
|
"enable_licensing": false,
|
||||||
"features_by_license": {
|
"features_by_license": {
|
||||||
"basic": [
|
"basic": [
|
||||||
"support"
|
"support"
|
||||||
|
|||||||
@ -62,6 +62,91 @@
|
|||||||
"compare_versions": "Compare Versions",
|
"compare_versions": "Compare Versions",
|
||||||
"no_license": "No License",
|
"no_license": "No License",
|
||||||
"tab_suggestions": "Suggestions",
|
"tab_suggestions": "Suggestions",
|
||||||
|
"tab_download": "Download",
|
||||||
|
"tab_add_music": "Add",
|
||||||
|
"tab_download_list": "List",
|
||||||
"tab_settings": "Settings",
|
"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."
|
||||||
}
|
}
|
||||||
@ -63,6 +63,91 @@
|
|||||||
"compare_versions": "Comparer les versions",
|
"compare_versions": "Comparer les versions",
|
||||||
"no_license": "Pas de licence",
|
"no_license": "Pas de licence",
|
||||||
"tab_suggestions": "Suggestions",
|
"tab_suggestions": "Suggestions",
|
||||||
|
"tab_download": "Télécharger",
|
||||||
|
"tab_add_music": "Ajouter",
|
||||||
|
"tab_download_list": "Liste",
|
||||||
"tab_settings": "Paramètres",
|
"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."
|
||||||
}
|
}
|
||||||
@ -2,5 +2,7 @@
|
|||||||
"theme": "dark",
|
"theme": "dark",
|
||||||
"lang": "fr",
|
"lang": "fr",
|
||||||
"window_size": {"width": 1000, "height": 600},
|
"window_size": {"width": 1000, "height": 600},
|
||||||
"maximized": true
|
"maximized": true,
|
||||||
|
"audio_format": "mp3",
|
||||||
|
"audio_quality": "320"
|
||||||
}
|
}
|
||||||
11
main.py
11
main.py
@ -1,5 +1,6 @@
|
|||||||
import sys
|
import sys
|
||||||
import app.utils.paths as paths
|
import app.utils.paths as paths
|
||||||
|
from PyQt6.QtCore import Qt
|
||||||
from PyQt6.QtWidgets import QApplication
|
from PyQt6.QtWidgets import QApplication
|
||||||
from PyQt6.QtGui import QIcon
|
from PyQt6.QtGui import QIcon
|
||||||
from app.ui.main_window import MainWindow
|
from app.ui.main_window import MainWindow
|
||||||
@ -52,6 +53,8 @@ def preload_application(progress_callback, splash=None):
|
|||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
global preloaded_window
|
global preloaded_window
|
||||||
|
|
||||||
|
QApplication.setAttribute(Qt.ApplicationAttribute.AA_ShareOpenGLContexts, True)
|
||||||
|
|
||||||
main_manager: MainManager = MainManager.get_instance()
|
main_manager: MainManager = MainManager.get_instance()
|
||||||
theme_manager = main_manager.get_theme_manager()
|
theme_manager = main_manager.get_theme_manager()
|
||||||
@ -93,8 +96,10 @@ def main() -> int:
|
|||||||
|
|
||||||
window = preloaded_window if preloaded_window else MainWindow()
|
window = preloaded_window if preloaded_window else MainWindow()
|
||||||
window.show()
|
window.show()
|
||||||
|
|
||||||
return app.exec()
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
sys.exit(main())
|
try:
|
||||||
|
sys.exit(main())
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
sys.exit(0)
|
||||||
@ -1,4 +1,7 @@
|
|||||||
PyQt6
|
PyQt6
|
||||||
|
PyQt6-WebEngine
|
||||||
pyinstaller
|
pyinstaller
|
||||||
python-dotenv
|
python-dotenv
|
||||||
requests
|
requests
|
||||||
|
yt-dlp
|
||||||
|
mutagen
|
||||||
Loading…
x
Reference in New Issue
Block a user