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