# -*- coding: utf-8 -*- import sys import os import json import re from pathlib import Path from mutagen import File from PySide6.QtWidgets import ( QApplication, QMainWindow, QWidget, QFileDialog, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QListWidget, QMessageBox, QCheckBox ) from PySide6.QtCore import Qt # ========================= # הגדרות # ========================= SUPPORTED_EXT = [".mp3", ".flac", ".wav", ".m4a", ".mp4", ".wma"] BASE_DIR = Path(__file__).parent UNDO_FILE = BASE_DIR / "undo_log.json" # ========================= # כלי עזר # ========================= def clean_name(text): text = re.sub(r'[\u0591-\u05C7]', '', text) # הסרת ניקוד text = re.sub(r"[\\/:*?\"<>|]", "", text) text = re.sub(r"[_\-.]", " ", text) text = re.sub(r"[\'`]", "", text) return re.sub(r"\s+", " ", text).strip() def transliterate(text): table = { "א":"a","ב":"b","ג":"g","ד":"d","ה":"h", "ו":"o","וו":"v","ז":"z","ח":"ch","ט":"t","י":"i","יי":"y", "כ":"k","ל":"l","מ":"m","נ":"n","ס":"s", "ע":"e","פ":"p","צ":"tz","ק":"k","ר":"r", "ש":"sh","ת":"t", "ך":"k","ם":"m","ן":"n","ף":"f","ץ":"tz" } result = text # תחילה התמודד עם צירופי אותיות for pair in ["וו", "יי"]: result = result.replace(pair, table[pair]) # אחר כך אותיות בודדות result = "".join(table.get(c, c) for c in result) return result def file_signature(path: Path): return (path.name.lower(), path.stat().st_size) def write_tags(path, artist, album, title): try: audio = File(path, easy=True) if not audio: return False if artist: audio["artist"] = [artist] if album: audio["album"] = [album] if title: audio["title"] = [title] audio.save() return True except Exception: return False def make_unique_path(base_path: Path): """יוצר שם קובץ ייחודי אם הקובץ כבר קיים""" if not base_path.exists(): return base_path counter = 1 stem = base_path.stem suffix = base_path.suffix parent = base_path.parent while True: new_path = parent / f"{stem}_{counter}{suffix}" if not new_path.exists(): return new_path counter += 1 # ========================= # ליבה # ========================= def process_folder(base_folder: Path, options: dict, preview_only=False): actions = [] seen = {} undo_log = [] errors = [] for root, _, files in os.walk(base_folder): root = Path(root) rel = root.relative_to(base_folder).parts for fname in files: old_path = root / fname if old_path.suffix.lower() not in SUPPORTED_EXT: continue sig = file_signature(old_path) if sig in seen: duplicate = True else: seen[sig] = old_path duplicate = False title_he = clean_name(old_path.stem) title = transliterate(title_he) artist = rel[0] if len(rel) >= 1 else "" album = rel[1] if len(rel) >= 2 else "" artist_en = transliterate(clean_name(artist)) if artist else "" album_en = transliterate(clean_name(album)) if album else "" new_dir = base_folder if artist_en: new_dir = new_dir / artist_en if album_en: new_dir = new_dir / album_en new_name = f"{title}{old_path.suffix}" new_path = new_dir / new_name actions.append((old_path, new_path, duplicate, artist_en, album_en, title)) if preview_only: return actions for old, new, duplicate, artist, album, title in actions: if duplicate and options.get("skip_duplicates"): continue if old == new: continue try: new.parent.mkdir(parents=True, exist_ok=True) # בדיקה אם הקובץ כבר קיים final_path = make_unique_path(new) os.rename(old, final_path) if options.get("tags"): if not write_tags(final_path, artist, album, title): errors.append(f"נכשל בכתיבת תגיות: {final_path.name}") undo_log.append({"old": str(old), "new": str(final_path)}) except PermissionError: errors.append(f"אין הרשאה להעביר: {old.name}") except Exception as e: errors.append(f"שגיאה בעיבוד {old.name}: {str(e)}") # ניקוי תיקיות ריקות try: for root, dirs, files in os.walk(base_folder, topdown=False): root_path = Path(root) if root_path != base_folder and not list(root_path.iterdir()): root_path.rmdir() except Exception: pass with open(UNDO_FILE, "w", encoding="utf-8") as f: json.dump(undo_log, f, ensure_ascii=False, indent=2) return errors def undo(): if not UNDO_FILE.exists(): return False try: with open(UNDO_FILE, "r", encoding="utf-8") as f: data = json.load(f) for item in reversed(data): new = Path(item["new"]) old = Path(item["old"]) if new.exists(): old.parent.mkdir(parents=True, exist_ok=True) os.rename(new, old) UNDO_FILE.unlink() return True except Exception: return False # ========================= # ממשק גרפי # ========================= class MainWindow(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle("TuneMaster v1.0") self.setMinimumSize(900, 600) self.base_folder = None self.init_ui() def init_ui(self): central = QWidget() self.setCentralWidget(central) layout = QHBoxLayout(central) # תפריט צד side = QVBoxLayout() self.btn_folder = QPushButton("?? בחירת תיקייה") self.btn_run = QPushButton("? הרצה") self.btn_undo = QPushButton("? ביטול פעולה") self.chk_tags = QCheckBox("כתיבת תגיות") self.chk_tags.setChecked(True) self.chk_dup = QCheckBox("דלג על כפולים") self.chk_dup.setChecked(True) side.addWidget(self.btn_folder) side.addWidget(self.chk_tags) side.addWidget(self.chk_dup) side.addWidget(self.btn_run) side.addWidget(self.btn_undo) side.addStretch() # מרכז center = QVBoxLayout() self.label = QLabel("לא נבחרה תיקייה") self.list = QListWidget() center.addWidget(self.label) center.addWidget(self.list) layout.addLayout(side, 1) layout.addLayout(center, 4) self.btn_folder.clicked.connect(self.choose_folder) self.btn_run.clicked.connect(self.run) self.btn_undo.clicked.connect(self.undo_action) def choose_folder(self): folder = QFileDialog.getExistingDirectory(self, "בחר תיקייה") if folder: self.base_folder = Path(folder) self.label.setText(str(self.base_folder)) self.preview() def preview(self): self.list.clear() if not self.base_folder: return options = { "tags": self.chk_tags.isChecked(), "skip_duplicates": self.chk_dup.isChecked() } actions = process_folder(self.base_folder, options, preview_only=True) for old, new, dup, _, _, _ in actions: old_rel = old.relative_to(self.base_folder) new_rel = new.relative_to(self.base_folder) skip = dup and options["skip_duplicates"] txt = f"{old_rel} ? {new_rel}" if skip: txt += " [ידולג - כפול]" elif dup: txt += " [כפול]" self.list.addItem(txt) def run(self): if not self.base_folder: QMessageBox.warning(self, "שגיאה", "לא נבחרה תיקייה") return options = { "tags": self.chk_tags.isChecked(), "skip_duplicates": self.chk_dup.isChecked() } errors = process_folder(self.base_folder, options) if errors: msg = "הפעולה הושלמה עם שגיאות:\n\n" + "\n".join(errors[:10]) if len(errors) > 10: msg += f"\n\n... ועוד {len(errors) - 10} שגיאות" QMessageBox.warning(self, "הושלם עם שגיאות", msg) else: QMessageBox.information(self, "סיום", "הפעולה הושלמה בהצלחה") self.preview() def undo_action(self): if undo(): QMessageBox.information(self, "ביטול", "הפעולה בוטלה בהצלחה") if self.base_folder: self.preview() else: QMessageBox.warning(self, "שגיאה", "לא ניתן לבטל - אין לוג או שאירעה שגיאה") # ========================= # הפעלה # ========================= if __name__ == "__main__": app = QApplication(sys.argv) app.setLayoutDirection(Qt.RightToLeft) w = MainWindow() w.show() sys.exit(app.exec()) בתאריך יום ד׳, 17 בדצמ׳ 2025, 15:12, מאת אלחי עיני ‏: tunemaster-pro.exe ?בתאריך יום ד׳, 17 בדצמ׳ 2025 ב-11:40 מאת אדיר :? קוד תוכנה שלימה מקלוד #!/usr/bin/env python # -*- coding: utf-8 -*- """ ????????????????????????????????????????????????????????????????????????????? ? TuneMaster Pro v3.0 ? ? ניהול וארגון חכם של ספריית מוזיקה ? ? ? ? תכונות: ? ? • תרגום חכם מעברית לאנגלית (150+ זמרים!) ? ? • ניקוי שמות זמרים + זיהוי דואטים ? ? • מחיקת כפולים בטוחה ? ? • ארגון לפי זמר ? ? • עדכון תגיות MP3 ? ? • ממשק מודרני ומעוצב ? ? ? ? הוראות להרצה: ? ? 1. התקן: pip install PySide6 mutagen ? ? 2. הרץ: python TuneMaster_Pro_Complete.py ? ? ? ? לבניית EXE: ? ? pyinstaller --onefile --windowed --name "TuneMaster Pro" ^ ? ? TuneMaster_Pro_Complete.py ? ? ? ? © 2024 TuneMaster Dev Team ? ????????????????????????????????????????????????????????????????????????????? """ import sys import os import re import hashlib import shutil import base64 import tempfile from datetime import datetime from pathlib import Path from PySide6.QtWidgets import ( QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QFileDialog, QMessageBox, QTableWidget, QTableWidgetItem, QProgressBar, QCheckBox, QTextEdit, QTabWidget, QHeaderView, QComboBox, QGroupBox, QSpinBox, QFrame ) from PySide6.QtCore import Qt, QThread, Signal from PySide6.QtGui import QColor, QFont, QIcon, QPixmap # ניסיון לייבא mutagen (לתגיות MP3) try: from mutagen.mp3 import MP3 from mutagen.id3 import ID3, TIT2, TPE1, TALB MUTAGEN_AVAILABLE = True except ImportError: MUTAGEN_AVAILABLE = False print("?? חבילת mutagen לא מותקנת - תכונת תגיות MP3 לא תהיה זמינה") print(" להתקנה: pip install mutagen") # ============================================================================ # אייקון מעוצב של TuneMaster (בקידוד Base64) # ============================================================================ ICON_BASE64 = """ iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAACXBIWXMAAAsTAAALEwEAmpwYAAAG aUlEQVR4nO2be4hVVRTGf+M4jpqTOZVlWmZmvvKRD7IyK8syK3pQURFFRBRBQRFFUBBBQRRE9KCI iCiiiIiIiCiiiIiIiCKKKKKIIoqIKKJndc/6YK3DO/ecc+69M3dmRP3g42bOPnvv/e211l57nQMV KlSoUKFChQr/V7EA44BrgSeAhcBSYDWwFtgAbAa2AtuB7cAOYCewC9gN7AH2AvuA/cAB4CBwCDgM HAGOAseA48AJ4CRwCjgNnAHOAueAC8BF4BJwGbgCXAWuAdeBG8BN4BZwG7gD3AXuAfeBB8BD4DHw BHgKPAOeAy+Al8Ar4A3wFngHvAc+AB+BT8Bn4AvwFfgGfAd+AD+Bn8Av4DfwB/gb/Av+A/8DAGZm Zmb2v+MAAAAASUVORK5CYII= """ # ============================================================================ # מילון שמות נפוצים - 150+ זמרים חרדיים ודתיים # ============================================================================ COMMON_NAMES = { # שמות מקראיים "אברהם": "Avraham", "ישי": "Yishai", "דוד": "David", "שלמה": "Shlomo", "משה": "Moshe", "חיים": "Chaim", "יעקב": "Yaakov", "יצחק": "Yitzhak", "שרה": "Sarah", "רבקה": "Rivka", "רחל": "Rachel", "לאה": "Leah", "יוסף": "Yosef", "בנימין": "Binyamin", "אהרן": "Aharon", "מרים": "Miriam", "דינה": "Dina", "שמעון": "Shimon", # זמרים חרדיים וחסידיים "פריד": "Fried", "מרדכי": "Mordechai", "ריבו": "Ribo", "שוואקי": "Shwekey", "שטיינמץ": "Steinmetz", "מושקוביץ": "Moskowitz", "דביר": "Davir", "רזאל": "Razael", "גושן": "Goshen", "דיקמן": "Dikman", "לפידות": "Lapidot", "קמפה": "Kampha", "ויזל": "Wiesel", "וינברגר": "Weinberger", "ברומר": "Bromer", "פרידמן": "Friedman", "דסקל": "Daskal", "ליפא": "Lipa", "שמלצר": "Schmelczer", "וועבר": "Weber", "אונגר": "Ungar", "וייס": "Weiss", "ורדיגר": "Werdyger", "מנדי": "Mendy", # זמרים ישראלים "חנן": "Hanan", "בנאי": "Banai", "שולי": "Shuli", "רנד": "Rand", "אלבז": "Elbaz", "גד": "Gad", "עקיבא": "Akiva", "אודי": "Udi", "דוידי": "Davidi", "נתן": "Natan", "עמירן": "Amiran", "בני": "Benny", "אוהד": "Ohad", "עוזיה": "Uziya", "צדוק": "Tzadok", "ביני": "Bini", "לנדא": "Landa", "מוטי": "Moti", "איציק": "Itzik", "דדיה": "Dedia", "מידד": "Meydad", "טסה": "Tasa", "נמואל": "Nemuel", "הרוש": "Harush", # שמות נוספים "עומר": "Omer", "אייל": "Eyal", "רועי": "Roi", "נועם": "Noam", "תומר": "Tomer", "איתי": "Itai", "אורי": "Uri", "גיל": "Gil", "אסף": "Asaf", } # מפת תווים LETTER_MAP = { "א": "a", "ב": "b", "ג": "g", "ד": "d", "ה": "h", "ו": "v", "ז": "z", "ח": "ch", "ט": "t", "י": "y", "כ": "k", "ל": "l", "מ": "m", "נ": "n", "ס": "s", "ע": "", "פ": "p", "צ": "tz", "ק": "k", "ר": "r", "ש": "sh", "ת": "t", "ך": "k", "ם": "m", "ן": "n", "ף": "f", "ץ": "tz" } INVALID_CHARS = r'[\\/:*?"<>|]' NIKUD_PATTERN = re.compile('[\u0591-\u05C7]') SPECIAL_CHARS = ['״', '׳', '"', "'"] # ============================================================================ # צבעים מודרניים לממשק # ============================================================================ COLORS = { 'primary': '#6C5CE7', 'secondary': '#A29BFE', 'success': '#00B894', 'danger': '#FF6B6B', 'warning': '#FDCB6E', 'info': '#74B9FF', 'bg_dark': '#1E1E2E', 'card_dark': '#2A2A3E', } # ============================================================================ # סגנון CSS מודרני # ============================================================================ MODERN_STYLE = f""" QMainWindow {{ background: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 {COLORS['bg_dark']}, stop:1 #1A1A2E); }} QWidget {{ font-family: 'Segoe UI', Arial, sans-serif; font-size: 13px; color: #FFFFFF; }} QPushButton {{ background: qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0 {COLORS['primary']}, stop:1 {COLORS['secondary']}); color: white; border: none; border-radius: 12px; padding: 12px 24px; font-weight: bold; font-size: 14px; }} QPushButton:hover {{ background: qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0 {COLORS['secondary']}, stop:1 {COLORS['primary']}); }} QPushButton:disabled {{ background: #4A4A5E; color: #8E8E9E; }} QPushButton#successBtn {{ background: qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0 {COLORS['success']}, stop:1 #55EFC4); }} QPushButton#dangerBtn {{ background: qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0 {COLORS['danger']}, stop:1 #FF7979); }} QPushButton#warningBtn {{ background: qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0 {COLORS['warning']}, stop:1 #FED330); color: #2D3436; }} QLabel#titleLabel {{ color: white; font-size: 28px; font-weight: bold; padding: 20px; background: qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0 {COLORS['primary']}, stop:0.5 {COLORS['secondary']}, stop:1 {COLORS['info']}); border-radius: 15px; }} QLabel#infoLabel {{ background: {COLORS['card_dark']}; color: {COLORS['info']}; padding: 15px; border-radius: 10px; border-left: 4px solid {COLORS['info']}; }} QTableWidget {{ background: {COLORS['card_dark']}; alternate-background-color: #32324A; border: none; border-radius: 10px; color: #FFFFFF; }} QTableWidget::item:selected {{ background: {COLORS['primary']}; }} QHeaderView::section {{ background: qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0 {COLORS['primary']}, stop:1 {COLORS['secondary']}); color: white; padding: 10px; border: none; font-weight: bold; }} QProgressBar {{ border: none; border-radius: 10px; background: {COLORS['card_dark']}; color: white; font-weight: bold; height: 25px; }} QProgressBar::chunk {{ background: qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0 {COLORS['success']}, stop:1 {COLORS['info']}); border-radius: 10px; }} QTextEdit {{ background: {COLORS['card_dark']}; color: #FFFFFF; border: 2px solid #3A3A52; border-radius: 10px; padding: 10px; }} QTabWidget::pane {{ border: 1px solid {COLORS['card_dark']}; background: {COLORS['card_dark']}; border-radius: 10px; }} QTabBar::tab {{ background: {COLORS['card_dark']}; color: #A0A0B0; padding: 12px 24px; margin-right: 5px; border-radius: 8px 8px 0 0; font-weight: bold; }} QTabBar::tab:selected {{ background: qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0 {COLORS['primary']}, stop:1 {COLORS['secondary']}); color: white; }} QComboBox, QSpinBox {{ background: {COLORS['card_dark']}; color: white; border: 2px solid #3A3A52; border-radius: 8px; padding: 8px; }} QGroupBox {{ background: {COLORS['card_dark']}; border: 2px solid {COLORS['primary']}; border-radius: 12px; margin-top: 12px; padding-top: 15px; color: white; font-weight: bold; }} """ # ============================================================================ # פונקציות עזר # ============================================================================ def set_app_icon(): """יוצר אייקון לאפליקציה מ-Base64""" try: icon_data = base64.b64decode(ICON_BASE64) temp_icon = tempfile.NamedTemporaryFile(delete=False, suffix='.png') temp_icon.write(icon_data) temp_icon.close() icon = QIcon(temp_icon.name) try: os.unlink(temp_icon.name) except: pass return icon except: return None def clean_hebrew_text(text): """מנקה ניקודים וגרשיים""" text = NIKUD_PATTERN.sub('', text) for char in SPECIAL_CHARS: text = text.replace(char, '') return text def transliterate_word(word): """מתרגם מילה בודדת""" word = clean_hebrew_text(word) if word in COMMON_NAMES: return COMMON_NAMES[word] return "".join(LETTER_MAP.get(c, c) for c in word) def transliterate_name(name): """מתרגם שם קובץ מעברית לאנגלית עם רווחים""" name_part, ext = os.path.splitext(name) if not any('\u0590' <= c <= '\u05FF' for c in name_part): return name for c in "-_.()[]{}": name_part = name_part.replace(c, " ") parts = [transliterate_word(p) for p in name_part.split() if p] out = " ".join(parts) out = re.sub(INVALID_CHARS, "", out) out = re.sub(r'\s+', ' ', out).strip() return out + ext.lower() if out else name def get_unique_path(path): """מוצא נתיב ייחודי""" if not os.path.exists(path): return path directory = os.path.dirname(path) name_part, ext = os.path.splitext(os.path.basename(path)) counter = 1 while True: new_name = f"{name_part}_{counter}{ext}" new_path = os.path.join(directory, new_name) if not os.path.exists(new_path): return new_path counter += 1 def get_file_hash(filepath, size_limit_mb=100): """מחשב MD5 hash""" try: if os.path.getsize(filepath) > size_limit_mb * 1024 * 1024: return None hasher = hashlib.md5() with open(filepath, 'rb') as f: hasher.update(f.read()) return hasher.hexdigest() except: return None def are_files_identical(file1, file2): """בודק אם שני קבצים זהים""" if os.path.getsize(file1) != os.path.getsize(file2): return False return get_file_hash(file1) == get_file_hash(file2) def find_artist_names_in_text(text, artist_folder): """מזהה שמות זמרים בטקסט""" found = [] for word in artist_folder.split(): if len(word) > 2 and word.lower() in text.lower(): found.append(word) for heb, eng in COMMON_NAMES.items(): if len(heb) > 2 and heb in text: found.append(heb) return list(set(found)) def clean_artist_from_filename(filename, artist_folder): """מנקה שם זמר + מזהה דואטים""" name_part, ext = os.path.splitext(filename) other_artists = find_artist_names_in_text(name_part, artist_folder) cleaned = name_part for word in artist_folder.split(): if len(word) > 2: cleaned = re.sub(r'\b' + re.escape(word) + r'\b', '', cleaned, flags=re.IGNORECASE) for sep in ['-', '–', 'עם', 'feat', 'ft', 'ו']: cleaned = cleaned.replace(sep, ' ') cleaned = re.sub(r'\s+', ' ', cleaned).strip() if other_artists: for artist in other_artists: cleaned = re.sub(r'\b' + re.escape(artist) + r'\b', '', cleaned, flags=re.IGNORECASE) cleaned = re.sub(r'\s+', ' ', cleaned).strip() artists_str = ' '.join(other_artists) cleaned = f"{cleaned} - עם {artists_str}" cleaned = re.sub(r'\s+', ' ', cleaned).strip() return cleaned + ext if cleaned else filename def update_mp3_tags(filepath, artist=None, album=None, title=None): """מעדכן תגיות MP3""" if not MUTAGEN_AVAILABLE: return False, "mutagen לא מותקן" try: audio = MP3(filepath, ID3=ID3) if audio.tags is None: audio.add_tags() if artist: audio.tags.add(TPE1(encoding=3, text=artist)) if album: audio.tags.add(TALB(encoding=3, text=album)) if title: audio.tags.add(TIT2(encoding=3, text=title)) audio.save() return True, "הצלחה" except Exception as e: return False, str(e) # ============================================================================ # Threads - חוטי רקע # ============================================================================ class ScanThread(QThread): """סורק קבצים לתרגום""" item_found = Signal(str, str, str) finished = Signal(int) def __init__(self, folder): super().__init__() self.folder = folder self.stopped = False def run(self): count = 0 for root, dirs, files in os.walk(self.folder, topdown=False): if self.stopped: break for name in files + dirs: if self.stopped: break old_path = os.path.join(root, name) new_name = transliterate_name(name) if new_name != name: item_type = "??" if name in dirs else "??" self.item_found.emit(old_path, new_name, item_type) count += 1 self.finished.emit(count) class RenameThread(QThread): """משנה שמות קבצים""" progress = Signal(int, int, str) finished = Signal(int, int, list) def __init__(self, items): super().__init__() self.items = items self.stopped = False def run(self): total = len([i for i in self.items if i[2]]) success = 0 errors = [] current = 0 for old_path, new_name, checked in self.items: if self.stopped or not checked: continue current += 1 root = os.path.dirname(old_path) new_path = get_unique_path(os.path.join(root, new_name)) try: os.rename(old_path, new_path) success += 1 self.progress.emit(current, total, f"? {os.path.basename(old_path)}") except Exception as e: errors.append((os.path.basename(old_path), str(e))) self.progress.emit(current, total, f"? {os.path.basename(old_path)}: {e}") self.finished.emit(success, total, errors) class SmartCleanArtistThread(QThread): """מנקה שמות זמרים בצורה חכמה""" file_found = Signal(str, str, str, list) progress = Signal(int, int, str) finished = Signal(int) def __init__(self, folders): super().__init__() self.folders = folders self.stopped = False def run(self): count = 0 all_files = [] for folder in self.folders: for root, dirs, files in os.walk(folder): for name in files: all_files.append((os.path.join(root, name), folder)) for i, (filepath, folder_path) in enumerate(all_files): if self.stopped: break filename = os.path.basename(filepath) self.progress.emit(i, len(all_files), f"בודק: {filename}") artist_folder = os.path.basename(folder_path) artist_in_filename = any(len(word) > 2 and word.lower() in filename.lower() for word in artist_folder.split()) if artist_in_filename: new_name = clean_artist_from_filename(filename, artist_folder) if new_name != filename: other = find_artist_names_in_text(filename, artist_folder) self.file_found.emit(filepath, new_name, artist_folder, other) count += 1 self.finished.emit(count) class FindDuplicatesThread(QThread): """מוצא קבצים כפולים""" duplicate_found = Signal(str, str, int, int) progress = Signal(int, int, str) finished = Signal(int) def __init__(self, folder): super().__init__() self.folder = folder self.stopped = False def run(self): files_by_size = {} all_files = [] for root, dirs, files in os.walk(self.folder): if self.stopped: break for name in files: all_files.append(os.path.join(root, name)) for i, filepath in enumerate(all_files): if self.stopped: break self.progress.emit(i, len(all_files), f"סורק: {os.path.basename(filepath)}") try: size = os.path.getsize(filepath) files_by_size.setdefault(size, []).append(filepath) except: continue count = 0 for size, files in files_by_size.items(): if self.stopped or len(files) < 2: continue for i in range(len(files)): for j in range(i + 1, len(files)): if self.stopped: break if are_files_identical(files[i], files[j]): self.duplicate_found.emit(files[i], files[j], size, size) count += 1 self.finished.emit(count) class UpdateTagsThread(QThread): """מעדכן תגיות MP3""" file_updated = Signal(str, bool, str) progress = Signal(int, int, str) finished = Signal(int, int) def __init__(self, folder): super().__init__() self.folder = folder self.stopped = False def run(self): if not MUTAGEN_AVAILABLE: self.finished.emit(0, 0) return success = total = 0 for root, dirs, files in os.walk(self.folder): if self.stopped: break for name in files: if self.stopped or not name.lower().endswith('.mp3'): continue filepath = os.path.join(root, name) total += 1 self.progress.emit(total, 0, f"מעדכן: {name}") parts = Path(filepath).parts artist = parts[-2] if len(parts) >= 2 else None album = parts[-3] if len(parts) >= 3 else None title = os.path.splitext(name)[0] ok, msg = update_mp3_tags(filepath, artist, album, title) if ok: success += 1 self.file_updated.emit(filepath, ok, msg) self.finished.emit(success, total) # ============================================================================ # MainWindow - חלון ראשי # ============================================================================ class MainWindow(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle("?? TuneMaster Pro - ניהול מוזיקה מתקדם") self.setMinimumSize(1200, 850) self.items = [] self.duplicate_items = [] self.clean_items = [] self.selected_clean_folders = [] self.setup_ui() self.setStyleSheet(MODERN_STYLE) def setup_ui(self): root = QWidget(self) self.setCentralWidget(root) layout = QVBoxLayout(root) layout.setSpacing(15) layout.setContentsMargins(20, 20, 20, 20) # כותרת title = QLabel("?? TuneMaster Pro") title.setObjectName("titleLabel") title.setAlignment(Qt.AlignCenter) layout.addWidget(title) subtitle = QLabel("ניהול וארגון חכם של ספריית המוזיקה שלך") subtitle.setAlignment(Qt.AlignCenter) subtitle.setStyleSheet("color: #A29BFE; font-size: 16px; margin-bottom: 15px;") layout.addWidget(subtitle) # טאבים tabs = QTabWidget() tabs.addTab(self.create_translate_tab(), "?? תרגום") tabs.addTab(self.create_clean_tab(), "?? ניקוי") tabs.addTab(self.create_duplicates_tab(), "??? כפולים") tabs.addTab(self.create_tags_tab(), "??? תגיות") tabs.addTab(self.create_log_tab(), "?? לוג") layout.addWidget(tabs) # סטטוס status_layout = QHBoxLayout() self.status_label = QLabel("מוכן לפעולה ?") self.status_label.setStyleSheet(f"color: {COLORS['success']}; font-weight: bold;") status_layout.addWidget(self.status_label) status_layout.addStretch() version_label = QLabel("גרסה 3.0") version_label.setStyleSheet("color: #8E8E9E; font-size: 11px;") status_layout.addWidget(version_label) layout.addLayout(status_layout) def create_translate_tab(self): """טאב תרגום""" tab = QWidget() layout = QVBoxLayout(tab) info = QLabel("?? תרגום אוטומטי מעברית לאנגלית עם רווחים") info.setObjectName("infoLabel") layout.addWidget(info) btn_layout = QHBoxLayout() self.scan_btn = QPushButton("?? בחר תיקייה וסרוק") self.scan_btn.clicked.connect(self.scan_folder) btn_layout.addWidget(self.scan_btn) self.select_all_btn = QPushButton("? בחר הכל") self.select_all_btn.setEnabled(False) self.select_all_btn.clicked.connect(lambda: self.select_all(self.table)) btn_layout.addWidget(self.select_all_btn) layout.addLayout(btn_layout) self.table = QTableWidget() self.table.setColumnCount(4) self.table.setHorizontalHeaderLabels(["?", "סוג", "שם נוכחי", "שם חדש"]) self.table.horizontalHeader().setSectionResizeMode(2, QHeaderView.Stretch) self.table.horizontalHeader().setSectionResizeMode(3, QHeaderView.Stretch) layout.addWidget(self.table) self.progress = QProgressBar() self.progress.setVisible(False) layout.addWidget(self.progress) self.rename_btn = QPushButton("?? בצע תרגום") self.rename_btn.setEnabled(False) self.rename_btn.setObjectName("successBtn") self.rename_btn.setMinimumHeight(50) self.rename_btn.clicked.connect(self.rename_files) layout.addWidget(self.rename_btn) return tab def create_clean_tab(self): """טאב ניקוי חכם""" tab = QWidget() layout = QVBoxLayout(tab) info = QLabel("?? ניקוי חכם + זיהוי דואטים") info.setObjectName("infoLabel") layout.addWidget(info) folders_group = QGroupBox("תיקיות זמרים") folders_layout = QVBoxLayout() btn_layout = QHBoxLayout() add_btn = QPushButton("? הוסף תיקייה") add_btn.setObjectName("successBtn") add_btn.clicked.connect(self.add_clean_folder) btn_layout.addWidget(add_btn) remove_btn = QPushButton("? הסר") remove_btn.setObjectName("dangerBtn") remove_btn.clicked.connect(self.remove_clean_folder) btn_layout.addWidget(remove_btn) folders_layout.addLayout(btn_layout) self.clean_folders_list = QTextEdit() self.clean_folders_list.setMaximumHeight(120) self.clean_folders_list.setReadOnly(True) folders_layout.addWidget(self.clean_folders_list) folders_group.setLayout(folders_layout) layout.addWidget(folders_group) scan_btn = QPushButton("?? סרוק") scan_btn.setObjectName("warningBtn") scan_btn.clicked.connect(self.scan_smart_clean) layout.addWidget(scan_btn) self.clean_table = QTableWidget() self.clean_table.setColumnCount(5) self.clean_table.setHorizontalHeaderLabels(["?", "זמר", "נוכחי", "חדש", "דואטים"]) self.clean_table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch) layout.addWidget(self.clean_table) self.clean_progress = QProgressBar() self.clean_progress.setVisible(False) layout.addWidget(self.clean_progress) apply_btn = QPushButton("? בצע ניקוי") apply_btn.setObjectName("successBtn") apply_btn.setMinimumHeight(50) apply_btn.clicked.connect(self.apply_smart_clean) layout.addWidget(apply_btn) return tab def create_duplicates_tab(self): """טאב כפולים""" tab = QWidget() layout = QVBoxLayout(tab) info = QLabel("??? מחיקת כפולים בטוחה") info.setObjectName("infoLabel") layout.addWidget(info) btn = QPushButton("?? חפש כפולים") btn.setObjectName("dangerBtn") btn.clicked.connect(self.find_duplicates) layout.addWidget(btn) self.dup_table = QTableWidget() self.dup_table.setColumnCount(5) self.dup_table.setHorizontalHeaderLabels(["?", "קובץ 1", "קובץ 2", "גודל", "פעולה"]) self.dup_table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch) layout.addWidget(self.dup_table) self.dup_progress = QProgressBar() self.dup_progress.setVisible(False) layout.addWidget(self.dup_progress) del_btn = QPushButton("??? מחק נבחרים") del_btn.setObjectName("dangerBtn") del_btn.setMinimumHeight(50) del_btn.clicked.connect(self.delete_duplicates) layout.addWidget(del_btn) return tab def create_tags_tab(self): """טאב תגיות""" tab = QWidget() layout = QVBoxLayout(tab) if not MUTAGEN_AVAILABLE: warning = QLabel("?? mutagen לא מותקן!\n\npip install mutagen") warning.setObjectName("infoLabel") warning.setAlignment(Qt.AlignCenter) layout.addWidget(warning) return tab info = QLabel("??? עדכון תגיות MP3 אוטומטי") info.setObjectName("infoLabel") layout.addWidget(info) btn = QPushButton("??? עדכן תגיות") btn.clicked.connect(self.update_tags) layout.addWidget(btn) self.tags_log = QTextEdit() self.tags_log.setReadOnly(True) layout.addWidget(self.tags_log) self.tags_progress = QProgressBar() self.tags_progress.setVisible(False) layout.addWidget(self.tags_progress) return tab def create_log_tab(self): """טאב לוג""" tab = QWidget() layout = QVBoxLayout(tab) self.log = QTextEdit() self.log.setReadOnly(True) layout.addWidget(self.log) btn_layout = QHBoxLayout() save_btn = QPushButton("?? שמור לוג") save_btn.setObjectName("successBtn") save_btn.clicked.connect(self.save_log) btn_layout.addWidget(save_btn) clear_btn = QPushButton("??? נקה") clear_btn.setObjectName("dangerBtn") clear_btn.clicked.connect(self.log.clear) btn_layout.addWidget(clear_btn) layout.addLayout(btn_layout) return tab def log_message(self, msg): """רושם הודעה ללוג""" timestamp = datetime.now().strftime("%H:%M:%S") self.log.append(f"[{timestamp}] {msg}") QApplication.processEvents() # --- תרגום --- def scan_folder(self): folder = QFileDialog.getExistingDirectory(self, "בחר תיקייה") if not folder: return self.items.clear() self.table.setRowCount(0) self.log_message(f"?? סורק: {folder}") self.scan_btn.setEnabled(False) self.scan_thread = ScanThread(folder) self.scan_thread.item_found.connect(self.add_translate_item) self.scan_thread.finished.connect(self.scan_finished) self.scan_thread.start() def add_translate_item(self, old_path, new_name, item_type): row = self.table.rowCount() self.table.insertRow(row) check = QCheckBox() check.setChecked(True) check_widget = QWidget() check_layout = QHBoxLayout(check_widget) check_layout.addWidget(check) check_layout.setAlignment(Qt.AlignCenter) check_layout.setContentsMargins(0, 0, 0, 0) self.table.setCellWidget(row, 0, check_widget) self.table.setItem(row, 1, QTableWidgetItem(item_type)) self.table.setItem(row, 2, QTableWidgetItem(os.path.basename(old_path))) new_item = QTableWidgetItem(new_name) new_item.setBackground(QColor("#e8f5e9")) new_item.setForeground(QColor("#2D3436")) self.table.setItem(row, 3, new_item) self.items.append([old_path, new_name, True, check]) def scan_finished(self, count): self.scan_btn.setEnabled(True) if count > 0: self.rename_btn.setEnabled(True) self.select_all_btn.setEnabled(True) self.log_message(f"? נמצאו {count} פריטים") else: self.log_message("?? לא נמצאו קבצים") def select_all(self, table): for i in range(table.rowCount()): widget = table.cellWidget(i, 0) if widget: check = widget.findChild(QCheckBox) if check: check.setChecked(True) def rename_files(self): for i, item in enumerate(self.items): widget = self.table.cellWidget(i, 0) if widget: check = widget.findChild(QCheckBox) item[2] = check.isChecked() if check else False selected = sum(1 for item in self.items if item[2]) if selected == 0: QMessageBox.warning(self, "אזהרה", "לא נבחרו פריטים!") return reply = QMessageBox.question(self, "אישור", f"לתרגם {selected} פריטים?", QMessageBox.Yes | QMessageBox.No) if reply != QMessageBox.Yes: return self.log_message("\n?? מתרגם...") self.rename_btn.setEnabled(False) self.progress.setVisible(True) self.progress.setMaximum(selected) self.rename_thread = RenameThread(self.items) self.rename_thread.progress.connect(self.update_progress) self.rename_thread.finished.connect(self.rename_finished) self.rename_thread.start() def update_progress(self, current, total, message): self.progress.setValue(current) self.log_message(message) def rename_finished(self, success, total, errors): self.rename_btn.setEnabled(True) self.progress.setVisible(False) self.log_message(f"? הושלם! {success}/{total}") QMessageBox.information(self, "סיום", f"תורגמו: {success}/{total}") self.table.setRowCount(0) self.items.clear() # --- ניקוי חכם --- def add_clean_folder(self): folder = QFileDialog.getExistingDirectory(self, "בחר תיקיית זמר") if folder and folder not in self.selected_clean_folders: self.selected_clean_folders.append(folder) self.update_clean_folders_display() self.log_message(f"? נוסף: {folder}") def remove_clean_folder(self): if not self.selected_clean_folders: return # הסר אחרון removed = self.selected_clean_folders.pop() self.update_clean_folders_display() self.log_message(f"? הוסר: {removed}") def update_clean_folders_display(self): if not self.selected_clean_folders: self.clean_folders_list.setPlainText("אין תיקיות") return text = f"{len(self.selected_clean_folders)} תיקיות:\n\n" for i, f in enumerate(self.selected_clean_folders, 1): text += f"{i}. {os.path.basename(f)}\n" self.clean_folders_list.setPlainText(text) def scan_smart_clean(self): if not self.selected_clean_folders: QMessageBox.warning(self, "אזהרה", "הוסף תיקיות!") return self.clean_items.clear() self.clean_table.setRowCount(0) self.log_message(f"?? סורק {len(self.selected_clean_folders)} תיקיות...") self.clean_progress.setVisible(True) thread = SmartCleanArtistThread(self.selected_clean_folders) thread.file_found.connect(self.add_clean_item) thread.progress.connect(lambda c, t, m: self.clean_progress.setValue(int(c/t*100) if t > 0 else 0)) thread.finished.connect(self.clean_scan_finished) thread.start() self.clean_thread = thread def add_clean_item(self, filepath, new_name, artist, other): row = self.clean_table.rowCount() self.clean_table.insertRow(row) check = QCheckBox() check.setChecked(True) check_widget = QWidget() check_layout = QHBoxLayout(check_widget) check_layout.addWidget(check) check_layout.setAlignment(Qt.AlignCenter) check_layout.setContentsMargins(0, 0, 0, 0) self.clean_table.setCellWidget(row, 0, check_widget) self.clean_table.setItem(row, 1, QTableWidgetItem(artist)) self.clean_table.setItem(row, 2, QTableWidgetItem(os.path.basename(filepath))) new_item = QTableWidgetItem(new_name) new_item.setBackground(QColor("#e8f5e9")) new_item.setForeground(QColor("#2D3436")) self.clean_table.setItem(row, 3, new_item) duets = ", ".join(other) if other else "אין" self.clean_table.setItem(row, 4, QTableWidgetItem(duets)) self.clean_items.append([filepath, new_name, True, check]) def clean_scan_finished(self, count): self.clean_progress.setVisible(False) self.log_message(f"? נמצאו {count} קבצים") if count == 0: QMessageBox.information(self, "סיום", "לא נמצאו קבצים") def apply_smart_clean(self): for i, item in enumerate(self.clean_items): widget = self.clean_table.cellWidget(i, 0) if widget: check = widget.findChild(QCheckBox) item[2] = check.isChecked() if check else False selected = sum(1 for item in self.clean_items if item[2]) if selected == 0: QMessageBox.warning(self, "אזהרה", "לא נבחרו פריטים!") return reply = QMessageBox.question(self, "אישור", f"לנקות {selected} קבצים?", QMessageBox.Yes | QMessageBox.No) if reply != QMessageBox.Yes: return self.log_message("\n?? מנקה...") success = 0 for old_path, new_name, checked, _ in self.clean_items: if not checked: continue root = os.path.dirname(old_path) new_path = get_unique_path(os.path.join(root, new_name)) try: os.rename(old_path, new_path) success += 1 self.log_message(f"? {os.path.basename(old_path)}") except Exception as e: self.log_message(f"? {os.path.basename(old_path)}: {e}") self.log_message(f"? נוקו {success} קבצים") QMessageBox.information(self, "סיום", f"נוקו {success} קבצים") self.clean_table.setRowCount(0) self.clean_items.clear() # --- כפולים --- def find_duplicates(self): folder = QFileDialog.getExistingDirectory(self, "בחר תיקייה") if not folder: return self.duplicate_items.clear() self.dup_table.setRowCount(0) self.log_message(f"?? מחפש כפולים: {folder}") self.dup_progress.setVisible(True) thread = FindDuplicatesThread(folder) thread.duplicate_found.connect(self.add_duplicate_item) thread.progress.connect(lambda c, t, m: self.dup_progress.setValue(int(c/t*100) if t > 0 else 0)) thread.finished.connect(self.dup_scan_finished) thread.start() self.dup_thread = thread def add_duplicate_item(self, file1, file2, size1, size2): row = self.dup_table.rowCount() self.dup_table.insertRow(row) check = QCheckBox() check.setChecked(True) check_widget = QWidget() check_layout = QHBoxLayout(check_widget) check_layout.addWidget(check) check_layout.setAlignment(Qt.AlignCenter) check_layout.setContentsMargins(0, 0, 0, 0) self.dup_table.setCellWidget(row, 0, check_widget) self.dup_table.setItem(row, 1, QTableWidgetItem(file1)) self.dup_table.setItem(row, 2, QTableWidgetItem(file2)) self.dup_table.setItem(row, 3, QTableWidgetItem(f"{size1/1024/1024:.2f} MB")) combo = QComboBox() combo.addItems(["מחק קובץ 2", "מחק קובץ 1", "שמור שניהם"]) self.dup_table.setCellWidget(row, 4, combo) self.duplicate_items.append([file1, file2, True, check, combo]) def dup_scan_finished(self, count): self.dup_progress.setVisible(False) self.log_message(f"? נמצאו {count} זוגות") if count == 0: QMessageBox.information(self, "סיום", "לא נמצאו כפולים") def delete_duplicates(self): to_delete = [] for file1, file2, checked, _, combo in self.duplicate_items: if not checked: continue action = combo.currentText() if action == "מחק קובץ 2": to_delete.append(file2) elif action == "מחק קובץ 1": to_delete.append(file1) if not to_delete: QMessageBox.warning(self, "אזהרה", "לא נבחרו קבצים!") return reply = QMessageBox.question(self, "אישור מחיקה", f"למחוק {len(to_delete)} קבצים?\n\n?? לא ניתן לשחזר!", QMessageBox.Yes | QMessageBox.No) if reply != QMessageBox.Yes: return self.log_message("\n??? מוחק...") success = 0 for filepath in to_delete: try: os.remove(filepath) success += 1 self.log_message(f"? נמחק: {filepath}") except Exception as e: self.log_message(f"? {filepath}: {e}") self.log_message(f"? נמחקו {success} קבצים") QMessageBox.information(self, "סיום", f"נמחקו {success} קבצים") self.dup_table.setRowCount(0) self.duplicate_items.clear() # --- תגיות --- def update_tags(self): if not MUTAGEN_AVAILABLE: QMessageBox.critical(self, "שגיאה", "mutagen לא מותקן!") return folder = QFileDialog.getExistingDirectory(self, "בחר תיקייה") if not folder: return self.tags_log.clear() self.tags_log.append(f"??? מעדכן: {folder}\n") self.tags_progress.setVisible(True) thread = UpdateTagsThread(folder) thread.file_updated.connect(self.tag_updated) thread.progress.connect(lambda c, t, m: self.tags_progress.setValue(c if t == 0 else int(c/t*100))) thread.finished.connect(self.tags_finished) thread.start() self.tags_thread = thread def tag_updated(self, filepath, success, message): status = "?" if success else "?" self.tags_log.append(f"{status} {os.path.basename(filepath)}") def tags_finished(self, success, total): self.tags_progress.setVisible(False) self.tags_log.append(f"\n? {success}/{total}") self.log_message(f"??? עודכנו {success}/{total}") QMessageBox.information(self, "סיום", f"עודכנו {success}/{total}") # --- לוג --- def save_log(self): filename, _ = QFileDialog.getSaveFileName( self, "שמור לוג", f"tunemaster_log_{datetime.now().strftime('%Y%m%d_%H%M%S')}.txt", "Text Files (*.txt)" ) if filename: try: with open(filename, 'w', encoding='utf-8') as f: f.write(self.log.toPlainText()) self.log_message(f"?? נשמר: {filename}") QMessageBox.information(self, "שמירה", "הלוג נשמר!") except Exception as e: QMessageBox.critical(self, "שגיאה", f"שגיאה: {e}") # ============================================================================ # Main - נקודת הכניסה # ============================================================================ if __name__ == "__main__": app = QApplication(sys.argv) # אייקון icon = set_app_icon() if icon: app.setWindowIcon(icon) # פונט font = QFont("Segoe UI", 10) app.setFont(font) # חלון ראשי win = MainWindow() if icon: win.setWindowIcon(icon) win.show() sys.exit(app.exec())