aboutsummaryrefslogtreecommitdiffstats
path: root/gui.py
diff options
context:
space:
mode:
authorPinapelz <yukais@pinapelz.com>2026-09-27 15:31:06 -0700
committerPinapelz <yukais@pinapelz.com>2026-09-27 15:33:17 -0700
commit0b04663e417fa86b7aa8700684737effa367fcfe (patch)
tree230bb2d9cd0b42ddc64d502238471767dc3326e6 /gui.py
parent89c4a9ad5258bca27fcdc34df4956d4ffca20b25 (diff)
feat: initial 2.0 - expergo
- support fetching album art - full multiproc GUI - supporting in-place tag editing via GUI
Diffstat (limited to 'gui.py')
-rw-r--r--gui.py1323
1 files changed, 1323 insertions, 0 deletions
diff --git a/gui.py b/gui.py
new file mode 100644
index 0000000..66f0a12
--- /dev/null
+++ b/gui.py
@@ -0,0 +1,1323 @@
+import os
+import shutil
+import subprocess
+import sys
+import threading
+from concurrent.futures import ThreadPoolExecutor, as_completed
+from pathlib import Path
+from types import SimpleNamespace
+from PyQt6.QtCore import (
+ QAbstractTableModel,
+ QElapsedTimer,
+ QModelIndex,
+ QSize,
+ QSortFilterProxyModel,
+ Qt,
+ QThread,
+ QTimer,
+ pyqtSignal,
+)
+from PyQt6.QtGui import QAction, QActionGroup, QBrush, QColor, QIcon, QKeySequence, QPixmap
+from PyQt6.QtWidgets import (
+ QAbstractItemView,
+ QApplication,
+ QCheckBox,
+ QDialog,
+ QFileDialog,
+ QFrame,
+ QHBoxLayout,
+ QHeaderView,
+ QInputDialog,
+ QLabel,
+ QLineEdit,
+ QListView,
+ QListWidget,
+ QListWidgetItem,
+ QMainWindow,
+ QMenu,
+ QMessageBox,
+ QPlainTextEdit,
+ QProgressBar,
+ QPushButton,
+ QSizePolicy,
+ QSpinBox,
+ QSplitter,
+ QStyle,
+ QTableView,
+ QTableWidget,
+ QTableWidgetItem,
+ QToolBar,
+ QToolButton,
+ QVBoxLayout,
+ QWidget,
+)
+
+import covers
+import expergo as core
+from models import (
+ ART_COL,
+ FIELDS,
+ HEADERS,
+ LRC_COL,
+ FolderLayout,
+ Track,
+ apply_cover,
+ auto_cover,
+ fetch_lrc,
+ find_flacs,
+ flac_name,
+ move_track,
+ plan_reorganize,
+ remove_empty_dirs,
+ save_track,
+ write_tags,
+)
+DIRTY_BRUSH = QBrush(QColor(255, 190, 0, 70))
+MISSING_BRUSH = QBrush(QColor(220, 60, 60))
+EDIT_TRIGGERS = (
+ QAbstractItemView.EditTrigger.DoubleClicked
+ | QAbstractItemView.EditTrigger.EditKeyPressed
+ | QAbstractItemView.EditTrigger.AnyKeyPressed
+)
+
+os.environ["PATH"] = str(Path(sys.executable).parent) + os.pathsep + os.environ.get("PATH", "")
+REQUIRED_TOOLS = ("ffmpeg", "flac", "ffmpeg-normalize")
+
+
+def tools_ok(parent) -> bool:
+ missing = [tool for tool in REQUIRED_TOOLS if not shutil.which(tool)]
+ if missing:
+ QMessageBox.critical(parent, "Missing tools", f"Not found on PATH: {', '.join(missing)}\nSee README.md.")
+ return not missing
+
+
+def std_icon(theme: str, fallback: QStyle.StandardPixmap) -> QIcon:
+ return QIcon.fromTheme(theme, QApplication.style().standardIcon(fallback))
+
+
+class Cancelled(Exception):
+ pass
+
+
+class Worker(QThread):
+ """Runs fn(item) for every item on a thread pool, reporting log lines and progress."""
+
+ log = pyqtSignal(str)
+ progress = pyqtSignal(int, int)
+ current = pyqtSignal(str)
+
+ def __init__(self, items, fn, workers: int = 1, parent=None):
+ super().__init__(parent)
+ self.items, self.fn, self.workers = list(items), fn, max(1, workers)
+ self.results = []
+ self._stop = False
+
+ def stop(self):
+ self._stop = True
+
+ def _run_one(self, item):
+ if self._stop:
+ raise Cancelled()
+ name = getattr(item, "name", None)
+ if name:
+ self.current.emit(name)
+ return self.fn(item)
+
+ def run(self):
+ total, done = len(self.items), 0
+ self.progress.emit(0, total)
+ with ThreadPoolExecutor(max_workers=self.workers) as executor:
+ futures = {executor.submit(self._run_one, item): item for item in self.items}
+ for future in as_completed(futures):
+ item = futures[future]
+ try:
+ result = future.result()
+ self.results.append((item, result))
+ if isinstance(result, str) and result.strip():
+ self.log.emit(result.strip())
+ except Cancelled:
+ pass
+ except subprocess.CalledProcessError as e:
+ self.log.emit(f"ERROR {item.name}: {e}\n{(e.stderr or '')[-800:]}")
+ except Exception as e:
+ self.log.emit(f"ERROR {getattr(item, 'name', item)}: {e}")
+ done += 1
+ self.progress.emit(done, total)
+ if self._stop:
+ self.log.emit("Stopped.")
+
+
+# Background threads not parented to a widget, kept alive until they finish so closing a dialog can't
+# destroy a QThread that is still blocked on the network.
+_live_threads: set[QThread] = set()
+
+
+class Task(QThread):
+ progress = pyqtSignal(object)
+ done = pyqtSignal(object)
+ failed = pyqtSignal(str)
+
+ def __init__(self, fn):
+ super().__init__()
+ self.fn = fn
+
+ def run(self):
+ try:
+ self.done.emit(self.fn(self))
+ except Exception as e:
+ self.failed.emit(str(e))
+
+ def start(self):
+ _live_threads.add(self)
+ self.finished.connect(lambda: _live_threads.discard(self))
+ super().start()
+
+
+def format_duration(seconds: float) -> str:
+ seconds = int(seconds)
+ return f"{seconds // 3600}:{seconds // 60 % 60:02d}:{seconds % 60:02d}" if seconds >= 3600 else f"{seconds // 60}:{seconds % 60:02d}"
+
+
+class ProgressStrip(QFrame):
+ def __init__(self, stop_action: QAction | None = None):
+ super().__init__()
+ self.setFrameShape(QFrame.Shape.StyledPanel)
+ self.title = QLabel()
+ self.title.setStyleSheet("font-weight: bold;")
+ self.bar = QProgressBar()
+ self.bar.setMinimumWidth(200)
+ self.detail = QLabel()
+ self.detail.setStyleSheet("color: palette(mid);")
+ # Ignored width so long filenames get clipped instead of stretching the window
+ self.detail.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred)
+ self.clock = QLabel()
+ self._elapsed = QElapsedTimer()
+ self._timer = QTimer(self)
+ self._timer.setInterval(1000)
+ self._timer.timeout.connect(self._tick)
+ self._done = self._total = 0
+
+ row = QHBoxLayout(self)
+ row.setContentsMargins(8, 4, 8, 4)
+ row.addWidget(self.title)
+ row.addWidget(self.bar, 2)
+ row.addWidget(self.detail, 3)
+ row.addWidget(self.clock)
+ if stop_action:
+ stop = QToolButton()
+ stop.setDefaultAction(stop_action)
+ stop.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon)
+ row.addWidget(stop)
+ self.hide()
+
+ def start(self, title: str, total: int = 0):
+ self.title.setText(title)
+ self.detail.clear()
+ self._elapsed.start()
+ self._timer.start()
+ self.set_progress(0, total)
+ self.show()
+
+ def set_title(self, title: str):
+ self.title.setText(title)
+
+ def set_progress(self, done: int, total: int):
+ self._done, self._total = done, total
+ if total == 0 or (total == 1 and done == 0):
+ self.bar.setRange(0, 0) # unknown / single long step: show a busy animation instead of 0%
+ else:
+ self.bar.setRange(0, total)
+ self.bar.setValue(done)
+ self.bar.setFormat(f"{done} / {total} (%p%)")
+ self._tick()
+
+ def set_detail(self, text: str):
+ self.detail.setText(text)
+ self.detail.setToolTip(text)
+
+ def _tick(self):
+ seconds = self._elapsed.elapsed() / 1000 if self._elapsed.isValid() else 0
+ text = format_duration(seconds)
+ if 0 < self._done < self._total:
+ text += f" · ~{format_duration(seconds / self._done * (self._total - self._done))} left"
+ self.clock.setText(text)
+
+ def finish(self):
+ self._timer.stop()
+ self.hide()
+
+
+class TrackModel(QAbstractTableModel):
+ def __init__(self):
+ super().__init__()
+ self.tracks: list[Track] = []
+
+ def set_tracks(self, tracks):
+ self.beginResetModel()
+ self.tracks = list(tracks)
+ self.endResetModel()
+
+ def add_tracks(self, tracks):
+ if not tracks:
+ return
+ start = len(self.tracks)
+ self.beginInsertRows(QModelIndex(), start, start + len(tracks) - 1)
+ self.tracks += tracks
+ self.endInsertRows()
+
+ def remove_rows(self, rows):
+ for row in sorted(rows, reverse=True):
+ self.beginRemoveRows(QModelIndex(), row, row)
+ del self.tracks[row]
+ self.endRemoveRows()
+
+ def refresh(self):
+ if self.tracks:
+ self.dataChanged.emit(self.index(0, 0), self.index(len(self.tracks) - 1, ART_COL))
+
+ def rowCount(self, parent=QModelIndex()):
+ return 0 if parent.isValid() else len(self.tracks)
+
+ def columnCount(self, parent=QModelIndex()):
+ return 0 if parent.isValid() else len(HEADERS)
+
+ def headerData(self, section, orientation, role=Qt.ItemDataRole.DisplayRole):
+ if orientation == Qt.Orientation.Horizontal and role == Qt.ItemDataRole.DisplayRole:
+ return HEADERS[section]
+ return super().headerData(section, orientation, role)
+
+ def data(self, index, role=Qt.ItemDataRole.DisplayRole):
+ track = self.tracks[index.row()]
+ col = index.column()
+ if col == LRC_COL:
+ if role == Qt.ItemDataRole.DisplayRole:
+ return track.lrc
+ if role == Qt.ItemDataRole.ForegroundRole and track.lrc != "OK":
+ return MISSING_BRUSH
+ if role == Qt.ItemDataRole.ToolTipRole:
+ return str(track.path.with_suffix(".lrc"))
+ return None
+ if col == ART_COL:
+ if role == Qt.ItemDataRole.DisplayRole:
+ return "OK" if track.art else "Missing"
+ if role == Qt.ItemDataRole.ForegroundRole and not track.art:
+ return MISSING_BRUSH
+ return None
+
+ key = FIELDS[col][0]
+ if role in (Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole):
+ return track.tags[key]
+ if role == Qt.ItemDataRole.BackgroundRole and track.is_dirty(key):
+ return DIRTY_BRUSH
+ if role == Qt.ItemDataRole.ToolTipRole:
+ if track.is_dirty(key):
+ return f"Original: {track.orig[key]}"
+ return str(track.path) if key == "FILE" else None
+ return None
+
+ def flags(self, index):
+ flags = super().flags(index)
+ if index.column() < LRC_COL:
+ flags |= Qt.ItemFlag.ItemIsEditable
+ return flags
+
+ def setData(self, index, value, role=Qt.ItemDataRole.EditRole):
+ if role != Qt.ItemDataRole.EditRole or index.column() >= LRC_COL:
+ return False
+ self.tracks[index.row()].tags[FIELDS[index.column()][0]] = str(value)
+ self.dataChanged.emit(index, index)
+ return True
+
+
+class FilterProxy(QSortFilterProxyModel):
+ def __init__(self):
+ super().__init__()
+ self.text = ""
+ self.missing_lrc = False
+ self.missing_art = False
+ self.setSortCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
+
+ def set_filter(self, text=None, missing_lrc=None, missing_art=None):
+ if text is not None:
+ self.text = text.lower()
+ if missing_lrc is not None:
+ self.missing_lrc = missing_lrc
+ if missing_art is not None:
+ self.missing_art = missing_art
+ self.invalidate()
+
+ def filterAcceptsRow(self, row, parent):
+ track = self.sourceModel().tracks[row]
+ if self.missing_lrc and track.lrc == "OK":
+ return False
+ if self.missing_art and track.art:
+ return False
+ return not self.text or any(self.text in v.lower() for v in track.tags.values())
+
+
+class TrackTable(QTableView):
+ def __init__(self, source: TrackModel):
+ super().__init__()
+ self.source = source
+ self.proxy = FilterProxy()
+ self.proxy.setSourceModel(source)
+ self.setModel(self.proxy)
+ self.setSortingEnabled(True)
+ self.sortByColumn(-1, Qt.SortOrder.AscendingOrder)
+ self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
+ self.setEditTriggers(EDIT_TRIGGERS)
+ self.setAlternatingRowColors(True)
+ self.setWordWrap(False)
+ self.verticalHeader().setVisible(False)
+ self.horizontalHeader().setStretchLastSection(True)
+ self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
+ self.customContextMenuRequested.connect(self._context_menu)
+ self.extra_actions: list[QAction] = []
+
+ def fit_columns(self):
+ self.resizeColumnsToContents()
+ for col in range(len(HEADERS)):
+ self.setColumnWidth(col, min(self.columnWidth(col), 350))
+
+ def selected_rows(self) -> list[int]:
+ return sorted({self.proxy.mapToSource(i).row() for i in self.selectionModel().selectedRows()})
+
+ def selected_tracks(self) -> list[Track]:
+ """Selected rows, falling back to the current row."""
+ rows = self.selected_rows()
+ if not rows and self.currentIndex().isValid():
+ rows = [self.proxy.mapToSource(self.currentIndex()).row()]
+ return [self.source.tracks[r] for r in rows]
+
+ def current_track(self) -> Track | None:
+ index = self.currentIndex()
+ if not index.isValid():
+ rows = self.selected_rows()
+ return self.source.tracks[rows[0]] if rows else None
+ return self.source.tracks[self.proxy.mapToSource(index).row()]
+
+ def target_tracks(self) -> list[Track]:
+ rows = self.selected_rows() or [
+ self.proxy.mapToSource(self.proxy.index(r, 0)).row() for r in range(self.proxy.rowCount())
+ ]
+ return [self.source.tracks[r] for r in rows]
+
+ def _context_menu(self, pos):
+ index = self.indexAt(pos)
+ rows = self.selected_rows()
+ if not index.isValid() or not rows:
+ return
+ col = index.column()
+ menu = QMenu(self)
+ if col < LRC_COL and not (col == 0 and len(rows) > 1):
+ menu.addAction(f"Set {HEADERS[col]} for {len(rows)} row(s)…").triggered.connect(
+ lambda: self._bulk_edit(rows, col, index.data())
+ )
+ menu.addAction("Revert changes").triggered.connect(lambda: self._revert(rows))
+ if self.extra_actions:
+ menu.addSeparator()
+ menu.addActions(self.extra_actions)
+ menu.exec(self.viewport().mapToGlobal(pos))
+
+ def _bulk_edit(self, rows, col, current):
+ value, ok = QInputDialog.getText(self, "Bulk edit", f"{HEADERS[col]}:", text=current or "")
+ if ok:
+ for row in rows:
+ self.source.tracks[row].tags[FIELDS[col][0]] = value
+ self.source.refresh()
+
+ def _revert(self, rows):
+ for row in rows:
+ track = self.source.tracks[row]
+ track.tags = dict(track.orig)
+ self.source.refresh()
+
+
+class ImportDialog(QDialog):
+ imported = pyqtSignal()
+
+ def __init__(self, dest: Path | None, workers: int, nolrc: bool, parent=None):
+ super().__init__(parent)
+ self.setWindowTitle("Add Songs")
+ self.resize(1000, 600)
+ self.setAcceptDrops(True)
+ self.worker = None
+ self._copy_lock = threading.Lock()
+
+ self.model = TrackModel()
+ self.table = TrackTable(self.model)
+
+ add_files = QPushButton("Add Files…")
+ add_files.clicked.connect(self._pick_files)
+ add_folder = QPushButton("Add Folder…")
+ add_folder.clicked.connect(self._pick_folder)
+ remove = QPushButton("Remove Selected")
+ remove.clicked.connect(lambda: self.model.remove_rows(self.table.selected_rows()))
+ top = QHBoxLayout()
+ for w in (add_files, add_folder, remove):
+ top.addWidget(w)
+ top.addStretch()
+ top.addWidget(QLabel("Tip: drag & drop FLACs or folders here"))
+
+ self.dest = QLineEdit(str(dest) if dest else "")
+ browse = QPushButton("Browse…")
+ browse.clicked.connect(self._pick_dest)
+ dest_row = QHBoxLayout()
+ dest_row.addWidget(QLabel("Destination:"))
+ dest_row.addWidget(self.dest, 1)
+ dest_row.addWidget(browse)
+
+ self.apply_rules = QCheckBox("Apply Echo rules")
+ self.apply_rules.setChecked(True)
+ self.apply_rules.setToolTip("Rename, resample, fix blocksize, normalize loudness and resize art after copying.\n"
+ "Untick to copy the files as-is (only your tag edits are written).")
+ self.apply_rules.toggled.connect(self._update_import_label)
+ self.organize = QCheckBox("Organize into Artist / Album folders")
+ self.organize.setToolTip("Uses Album Artist when set (keeps compilations together), otherwise Artist")
+ self.nolrc = QCheckBox("Skip LRC download")
+ self.nolrc.setChecked(nolrc)
+ self.workers = QSpinBox()
+ self.workers.setRange(1, 16)
+ self.workers.setValue(workers)
+ opts = QHBoxLayout()
+ opts.addWidget(self.apply_rules)
+ opts.addWidget(self.organize)
+ opts.addWidget(self.nolrc)
+ opts.addStretch()
+ opts.addWidget(QLabel("Workers:"))
+ opts.addWidget(self.workers)
+
+ self.log = QPlainTextEdit(readOnly=True)
+ self.log.setMaximumBlockCount(5000)
+ self.stop_action = QAction("Stop", self)
+ self.stop_action.setIcon(std_icon("process-stop", QStyle.StandardPixmap.SP_BrowserStop))
+ self.stop_action.triggered.connect(lambda: self.worker and self.worker.stop())
+ self.progress = ProgressStrip(self.stop_action)
+ self.import_btn = QPushButton("Import && Apply Rules")
+ self.import_btn.clicked.connect(self._import)
+ close = QPushButton("Close")
+ close.clicked.connect(self.close)
+ bottom = QHBoxLayout()
+ bottom.addWidget(self.progress, 1)
+ bottom.addStretch()
+ bottom.addWidget(self.import_btn)
+ bottom.addWidget(close)
+
+ splitter = QSplitter(Qt.Orientation.Vertical)
+ splitter.addWidget(self.table)
+ splitter.addWidget(self.log)
+ splitter.setSizes([400, 150])
+ self._update_import_label()
+
+ layout = QVBoxLayout(self)
+ layout.addLayout(top)
+ layout.addWidget(splitter, 1)
+ layout.addLayout(dest_row)
+ layout.addLayout(opts)
+ layout.addLayout(bottom)
+
+ def _update_import_label(self):
+ self.import_btn.setText("Import && Apply Rules" if self.apply_rules.isChecked() else "Import")
+
+ def dragEnterEvent(self, event):
+ if event.mimeData().hasUrls():
+ event.acceptProposedAction()
+
+ def dropEvent(self, event):
+ self.add_paths([u.toLocalFile() for u in event.mimeData().urls() if u.isLocalFile()])
+
+ def _pick_files(self):
+ files, _ = QFileDialog.getOpenFileNames(self, "Add FLAC files", "", "FLAC (*.flac)")
+ self.add_paths(files)
+
+ def _pick_folder(self):
+ folder = QFileDialog.getExistingDirectory(self, "Add folder of FLACs")
+ if folder:
+ self.add_paths([folder])
+
+ def _pick_dest(self):
+ folder = QFileDialog.getExistingDirectory(self, "Destination folder", self.dest.text())
+ if folder:
+ self.dest.setText(folder)
+
+ def add_paths(self, paths):
+ known = {t.path for t in self.model.tracks}
+ new = []
+ for path in find_flacs(paths):
+ if path in known:
+ continue
+ try:
+ new.append(Track.load(path))
+ except Exception as e:
+ self.log.appendPlainText(f"ERROR reading {path.name}: {e}")
+ self.model.add_tracks(new)
+ self.table.fit_columns()
+
+ def _import_one(self, track: Track, dest_root: Path, layout: FolderLayout | None,
+ apply_rules: bool, nolrc: bool) -> str:
+ with self._copy_lock:
+ dest_dir = layout.folder_for(track.tags) if layout else dest_root
+ dest_dir.mkdir(parents=True, exist_ok=True)
+ target = dest_dir / (flac_name(track.tags["FILE"]) if track.tags["FILE"].strip() else track.name)
+ if target.exists():
+ raise FileExistsError(f"{target} already exists")
+ target.touch()
+ shutil.copy2(track.path, target)
+ lrc = track.path.with_suffix(".lrc")
+ if lrc.exists():
+ shutil.copy2(lrc, target.with_suffix(".lrc"))
+ write_tags(target, track)
+ if apply_rules:
+ return f"Copied to {dest_dir}" + core.process_file(target.resolve(), nolrc)
+
+ lines = [f"Copied {target.name} to {dest_dir}"]
+ title, artist = track.tags["TITLE"].strip(), track.tags["ARTIST"].strip()
+ if not nolrc and not target.with_suffix(".lrc").exists() and title and artist:
+ found = core.download_lrc(target.with_suffix(".lrc"), title, artist)
+ lines.append(f" {'Fetched' if found else 'No'} LRC for {title} - {artist}")
+ return "\n".join(lines)
+
+ def _import(self):
+ if not self.model.tracks:
+ return
+ if not self.dest.text().strip():
+ QMessageBox.warning(self, "Add Songs", "Choose a destination folder first.")
+ return
+ apply_rules, nolrc = self.apply_rules.isChecked(), self.nolrc.isChecked()
+ if apply_rules and not tools_ok(self):
+ return
+ core._claimed_names.clear()
+ self.import_btn.setEnabled(False)
+ dest_root = Path(self.dest.text().strip()).expanduser().resolve()
+ layout = FolderLayout(dest_root) if self.organize.isChecked() else None
+ fn = lambda t: self._import_one(t, dest_root, layout, apply_rules, nolrc)
+ self.worker = Worker(self.model.tracks, fn, self.workers.value(), self)
+ self.worker.log.connect(self.log.appendPlainText)
+ self.worker.progress.connect(self.progress.set_progress)
+ self.worker.current.connect(self.progress.set_detail)
+ self.worker.finished.connect(self._done)
+ self.progress.start("Importing", len(self.worker.items))
+ self.worker.start()
+
+ def _done(self):
+ if not self.worker:
+ return
+ done = {id(track) for track, _ in self.worker.results}
+ self.model.remove_rows([i for i, t in enumerate(self.model.tracks) if id(t) in done])
+ failed = len(self.model.tracks)
+ self.log.appendPlainText(f"Imported {len(done)} file(s).")
+ if failed:
+ self.log.appendPlainText(f"{failed} failed; their copies may remain in the library (see log).")
+ self.worker = None
+ self.progress.finish()
+ self.import_btn.setEnabled(True)
+ self.imported.emit()
+
+ def closeEvent(self, event):
+ if self.worker:
+ QMessageBox.information(self, "Add Songs", "Import is still running.")
+ event.ignore()
+ else:
+ super().closeEvent(event)
+
+ def reject(self):
+ self.close()
+
+
+class ReorganizeDialog(QDialog):
+ """Preview of moving every track to root/Artist/Album/. Nothing is touched until the user accepts."""
+
+ def __init__(self, tracks: list[Track], root: Path, parent=None):
+ super().__init__(parent)
+ self.setWindowTitle("Reorganize Folders")
+ self.resize(1000, 600)
+ self.tracks, self.root = tracks, root
+ self.moves: list[SimpleNamespace] = []
+
+ intro = QLabel(f"Move every FLAC (and its .lrc) in <b>{root}</b> into <b>Artist / Album</b> folders. "
+ "Filenames are kept. Uses the saved tags on disk.")
+ intro.setWordWrap(True)
+ self.album_artist = QCheckBox("Use Album Artist when set")
+ self.album_artist.setChecked(True)
+ self.album_artist.setToolTip("Keeps compilations / features in one folder. Untick to always use the track Artist.")
+ self.album_artist.toggled.connect(self._refresh)
+ self.cleanup = QCheckBox("Remove empty folders afterwards")
+ self.cleanup.setChecked(True)
+ opts = QHBoxLayout()
+ opts.addWidget(self.album_artist)
+ opts.addWidget(self.cleanup)
+ opts.addStretch()
+
+ self.table = QTableWidget(0, 2)
+ self.table.setHorizontalHeaderLabels(["From", "To"])
+ self.table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
+ self.table.verticalHeader().setVisible(False)
+ self.table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
+ self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
+ self.table.setWordWrap(False)
+
+ self.summary = QLabel()
+ self.ok_btn = QPushButton()
+ self.ok_btn.setDefault(True)
+ self.ok_btn.clicked.connect(self.accept)
+ cancel = QPushButton("Cancel")
+ cancel.clicked.connect(self.reject)
+ bottom = QHBoxLayout()
+ bottom.addWidget(self.summary, 1)
+ bottom.addWidget(self.ok_btn)
+ bottom.addWidget(cancel)
+
+ layout = QVBoxLayout(self)
+ layout.addWidget(intro)
+ layout.addLayout(opts)
+ layout.addWidget(self.table, 1)
+ layout.addLayout(bottom)
+ self._refresh()
+
+ def _rel(self, path: Path) -> str:
+ try:
+ return str(path.relative_to(self.root))
+ except ValueError:
+ return str(path)
+
+ def _refresh(self):
+ plan = plan_reorganize(self.tracks, self.root, self.album_artist.isChecked())
+ self.moves = [m for m in plan if not m.conflict]
+ self.table.setRowCount(len(plan))
+ for row, move in enumerate(plan):
+ src = QTableWidgetItem(self._rel(move.src))
+ dst = QTableWidgetItem(self._rel(move.dst) + (" (conflict: skipped)" if move.conflict else ""))
+ src.setToolTip(str(move.src))
+ dst.setToolTip(str(move.dst))
+ if move.conflict:
+ dst.setForeground(MISSING_BRUSH)
+ self.table.setItem(row, 0, src)
+ self.table.setItem(row, 1, dst)
+ conflicts = len(plan) - len(self.moves)
+ in_place = len(self.tracks) - len(plan)
+ text = f"{len(self.moves)} to move · {in_place} already in place"
+ if conflicts:
+ text += f" · {conflicts} skipped (a file with the same name is already at the destination)"
+ self.summary.setText(text)
+ self.ok_btn.setText(f"Move {len(self.moves)} File(s)")
+ self.ok_btn.setEnabled(bool(self.moves))
+
+
+
+def section_label(text: str) -> QLabel:
+ label = QLabel(text.upper())
+ label.setStyleSheet("font-weight: bold; font-size: 11px; color: palette(mid); padding: 2px 0;")
+ return label
+
+
+class ArtPanel(QWidget):
+ def __init__(self, actions: list[QAction]):
+ super().__init__()
+ self._pixmap: QPixmap | None = None
+
+ self.image = QLabel(alignment=Qt.AlignmentFlag.AlignCenter)
+ self.image.setFrameShape(QFrame.Shape.StyledPanel)
+ self.image.setMinimumSize(160, 160)
+ self.image.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Ignored)
+ self.caption = QLabel(alignment=Qt.AlignmentFlag.AlignCenter, wordWrap=True)
+ self.info = QLabel(alignment=Qt.AlignmentFlag.AlignCenter)
+ self.info.setStyleSheet("color: palette(mid);")
+
+ buttons = QHBoxLayout()
+ buttons.addStretch()
+ for act in actions:
+ btn = QToolButton()
+ btn.setDefaultAction(act)
+ btn.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon)
+ buttons.addWidget(btn)
+ buttons.addStretch()
+
+ layout = QVBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.addWidget(section_label("Album Art"))
+ layout.addWidget(self.image, 1)
+ layout.addWidget(self.caption)
+ layout.addWidget(self.info)
+ layout.addLayout(buttons)
+ self.show_track(None)
+
+ def show_track(self, track: Track | None, selected: int = 0):
+ self._pixmap = None
+ self.info.clear()
+ if track is None:
+ self.caption.setText("No track selected")
+ else:
+ self.caption.setText(track.tags["ALBUM"] or track.tags["TITLE"] or track.name)
+ try:
+ pic = covers.read_cover(track.path)
+ except Exception as e:
+ pic = None
+ self.info.setText(f"Could not read: {e}")
+ if pic:
+ pixmap = QPixmap()
+ if pixmap.loadFromData(pic.data):
+ self._pixmap = pixmap
+ fmt = (pic.mime or "image").split("/")[-1].upper()
+ self.info.setText(f"{pixmap.width()}×{pixmap.height()} · {fmt} · {len(pic.data) // 1024} KB")
+ elif not self.info.text():
+ self.info.setText("No embedded album art")
+ if selected > 1:
+ self.info.setText(self.info.text() + f"\n{selected} tracks selected; edits apply to all")
+ self._rescale()
+
+ def _rescale(self):
+ if self._pixmap:
+ self.image.setPixmap(self._pixmap.scaled(
+ self.image.size() - QSize(8, 8),
+ Qt.AspectRatioMode.KeepAspectRatio,
+ Qt.TransformationMode.SmoothTransformation,
+ ))
+ else:
+ self.image.setPixmap(QPixmap())
+ self.image.setText("No art")
+
+ def resizeEvent(self, event):
+ super().resizeEvent(event)
+ self._rescale()
+
+
+class CoverDialog(QDialog):
+ THUMB = 180
+
+ def __init__(self, album: str, artist: str, count: int, parent=None):
+ super().__init__(parent)
+ self.setWindowTitle(f"Album Art · {count} track(s)")
+ self.resize(900, 640)
+ self.source: tuple[str, str] | None = None
+ self._task: Task | None = None
+
+ self.album = QLineEdit(album, placeholderText="Album")
+ self.artist = QLineEdit(artist, placeholderText="Artist (optional)")
+ for edit in (self.album, self.artist):
+ edit.returnPressed.connect(self.search)
+ search = QPushButton("Search")
+ search.clicked.connect(self.search)
+ top = QHBoxLayout()
+ top.addWidget(self.album, 3)
+ top.addWidget(self.artist, 2)
+ top.addWidget(search)
+
+ self.results = QListWidget()
+ self.results.setViewMode(QListView.ViewMode.IconMode)
+ self.results.setIconSize(QSize(self.THUMB, self.THUMB))
+ self.results.setGridSize(QSize(self.THUMB + 30, self.THUMB + 70))
+ self.results.setResizeMode(QListView.ResizeMode.Adjust)
+ self.results.setMovement(QListView.Movement.Static)
+ self.results.setWordWrap(True)
+ self.results.setUniformItemSizes(True)
+ self.results.itemDoubleClicked.connect(self._apply)
+ self.results.currentItemChanged.connect(self._update_apply)
+
+ self.status = QLabel()
+ local = QPushButton("Local File…")
+ local.clicked.connect(self._pick_file)
+ self.apply_btn = QPushButton(f"Apply to {count} Track(s)")
+ self.apply_btn.setDefault(True)
+ self.apply_btn.setEnabled(False)
+ self.apply_btn.clicked.connect(self._apply)
+ cancel = QPushButton("Cancel")
+ cancel.clicked.connect(self.reject)
+ bottom = QHBoxLayout()
+ bottom.addWidget(local)
+ bottom.addWidget(self.status, 1)
+ bottom.addWidget(self.apply_btn)
+ bottom.addWidget(cancel)
+
+ self.progress = ProgressStrip()
+ self._loaded = 0
+
+ layout = QVBoxLayout(self)
+ layout.addLayout(top)
+ layout.addWidget(self.progress)
+ layout.addWidget(self.results, 1)
+ layout.addLayout(bottom)
+
+ self._placeholder = QPixmap(self.THUMB, self.THUMB)
+ self._placeholder.fill(QColor(128, 128, 128, 40))
+ if album.strip():
+ self.search()
+
+ def search(self):
+ album, artist = self.album.text().strip(), self.artist.text().strip()
+ if not album:
+ self.status.setText("Enter an album name to search.")
+ return
+ self.results.clear()
+ self.status.clear()
+ self._loaded = 0
+ self.progress.start("Searching MusicBrainz")
+ self.progress.set_detail(f"{album} — {artist}" if artist else album)
+
+ def run(task):
+ for event in covers.search_with_thumbnails(album, artist):
+ task.progress.emit(event)
+
+ self._task = Task(run)
+ self._task.progress.connect(self._on_event)
+ self._task.done.connect(self._on_done)
+ self._task.failed.connect(self._on_failed)
+ self._task.start()
+
+ def _stale(self) -> bool:
+ return self.sender() is not self._task
+
+ def _on_event(self, event):
+ if self._stale():
+ return
+ if event[0] == "releases":
+ for rel in event[1]:
+ item = QListWidgetItem(QIcon(self._placeholder), f"{rel['title']}\n{rel['artist']}\n{rel['details']}")
+ item.setData(Qt.ItemDataRole.UserRole, rel["id"])
+ item.setToolTip(f"{rel['title']}\n{rel['artist']}\n{rel['details']}\nMBID: {rel['id']}")
+ self.results.addItem(item)
+ if event[1]:
+ self.progress.set_title("Loading covers")
+ self.progress.set_detail(f"{len(event[1])} releases found")
+ self.progress.set_progress(0, len(event[1]))
+ else:
+ self.status.setText("No releases found.")
+ else:
+ _, index, data = event
+ self._loaded += 1
+ self.progress.set_progress(self._loaded, self.results.count())
+ item = self.results.item(index)
+ pixmap = QPixmap()
+ if data and pixmap.loadFromData(data):
+ item.setIcon(QIcon(pixmap))
+ else:
+ item.setText(item.text() + "\n(no cover)")
+ item.setFlags(Qt.ItemFlag.NoItemFlags)
+
+ def _on_done(self, _):
+ if self._stale():
+ return
+ self.progress.finish()
+ total = self.results.count()
+ with_art = sum(bool(self.results.item(i).flags() & Qt.ItemFlag.ItemIsEnabled) for i in range(total))
+ if total:
+ self.status.setText(f"{with_art} of {total} releases have cover art.")
+
+ def _on_failed(self, message):
+ if not self._stale():
+ self.progress.finish()
+ self.status.setText(f"Search failed: {message}")
+
+ def _update_apply(self, item, _previous=None):
+ self.apply_btn.setEnabled(bool(item and item.flags() & Qt.ItemFlag.ItemIsEnabled))
+
+ def _apply(self, *_):
+ item = self.results.currentItem()
+ if item and item.flags() & Qt.ItemFlag.ItemIsEnabled:
+ self.source = ("mbid", item.data(Qt.ItemDataRole.UserRole))
+ self.accept()
+
+ def _pick_file(self):
+ path, _ = QFileDialog.getOpenFileName(self, "Choose cover image", "", "Images (*.jpg *.jpeg *.png *.webp *.bmp)")
+ if path:
+ self.source = ("file", path)
+ self.accept()
+
+
+class MainWindow(QMainWindow):
+ def __init__(self, folder: Path | None = None):
+ super().__init__()
+ self.setWindowTitle("expergo")
+ self.resize(1300, 850)
+ self.folder: Path | None = None
+ self.worker: Worker | None = None
+
+ self.model = TrackModel()
+ self.model.dataChanged.connect(self._update_status)
+ self.model.modelReset.connect(self._update_status)
+ self.table = TrackTable(self.model)
+
+ def action(text, slot, icon=None, shortcut=None, tip=None, checkable=False):
+ act = QAction(text, self, checkable=checkable)
+ if slot:
+ act.triggered.connect(slot)
+ if icon:
+ act.setIcon(std_icon(*icon))
+ if shortcut:
+ act.setShortcut(QKeySequence(shortcut))
+ if tip:
+ act.setToolTip(tip)
+ act.setStatusTip(tip.replace("\n", " "))
+ return act
+
+ SP = QStyle.StandardPixmap
+ # Library
+ open_act = action("Open Folder…", self._pick_folder, ("document-open", SP.SP_DirOpenIcon), "Ctrl+O",
+ "Scan a folder recursively for FLAC files")
+ rescan_act = action("Rescan", self.rescan, ("view-refresh", SP.SP_BrowserReload), "F5")
+ save_act = action("Save", self.save_changes, ("document-save", SP.SP_DialogSaveButton), "Ctrl+S",
+ "Write edited tags / filenames to disk")
+ add_act = action("Add Songs…", self.add_songs, ("list-add", SP.SP_FileDialogNewFolder), "Ctrl+N",
+ "Copy new FLACs into the library (optionally applying the Echo rules)")
+ reorg_act = action("Reorganize…", self.reorganize, ("folder-new", SP.SP_FileDialogDetailedView),
+ "Ctrl+Shift+O", "Move every track in the library into Artist / Album folders (with preview)")
+ # Processing
+ rules_act = action("Apply Echo Rules", self.process, ("system-run", SP.SP_MediaPlay), "Ctrl+R",
+ "Rename, resample, fix blocksize, normalize, resize art, fetch LRC\n"
+ "(selected rows, or all visible rows if none selected)")
+ lrc_act = action("Fetch Missing LRC", self.fetch_missing_lrc, ("emblem-downloads", SP.SP_ArrowDown), "Ctrl+L",
+ "Download LRC for rows with missing/empty lyrics\n"
+ "(selected rows, or all visible rows if none selected)")
+ art_fetch_act = action("Fetch Missing Art", self.fetch_missing_art, ("folder-pictures", SP.SP_DirLinkIcon),
+ "Ctrl+Shift+E",
+ "Download the best-matching MusicBrainz cover for rows without album art,\n"
+ "once per album (selected rows, or all visible rows if none selected)")
+ self.stop_action = action("Stop", lambda: self.worker and self.worker.stop(),
+ ("process-stop", SP.SP_BrowserStop), tip="Stop after the files in progress")
+ self.stop_action.setEnabled(False)
+ # Album art (enabled only with a selection)
+ self.art_edit_action = action("Edit Art…", self.edit_cover, ("image-x-generic", SP.SP_FileDialogContentsView),
+ "Ctrl+E", "Pick album art from MusicBrainz / Cover Art Archive or a local file\n"
+ "(applies to all selected rows)")
+ self.art_remove_action = action("Remove", self.remove_cover, ("edit-delete", SP.SP_TrashIcon),
+ tip="Remove embedded album art from the selected rows")
+ self.art_actions = [self.art_edit_action, self.art_remove_action]
+ # Options
+ self.nolrc_action = action("Skip LRC Download", None, checkable=True,
+ tip="Don't fetch lyrics when applying the Echo rules or importing")
+ self.worker_group = QActionGroup(self)
+ for n in (1, 2, 4, 8):
+ act = action(f"{n} Worker{'s' if n > 1 else ''}", None, checkable=True)
+ act.setData(n)
+ act.setChecked(n == 1)
+ self.worker_group.addAction(act)
+
+ self.busy_actions = [open_act, rescan_act, save_act, add_act, reorg_act, rules_act, lrc_act, art_fetch_act]
+ menubar = self.menuBar()
+ file_menu = menubar.addMenu("&File")
+ file_menu.addActions([open_act, rescan_act, save_act])
+ file_menu.addSeparator()
+ file_menu.addActions([add_act, reorg_act])
+ file_menu.addSeparator()
+ file_menu.addAction(action("Quit", self.close, shortcut="Ctrl+Q"))
+ tracks_menu = menubar.addMenu("&Tracks")
+ tracks_menu.addActions([rules_act, lrc_act, art_fetch_act])
+ tracks_menu.addSeparator()
+ tracks_menu.addActions(self.art_actions)
+ tracks_menu.addSeparator()
+ tracks_menu.addAction(self.stop_action)
+ options_menu = QMenu("&Options", self)
+ options_menu.addAction(self.nolrc_action)
+ options_menu.addSeparator()
+ options_menu.addSection("Parallel Workers")
+ options_menu.addActions(self.worker_group.actions())
+ menubar.addMenu(options_menu)
+
+ # Toolbar: the common actions, in three groups
+ tb = QToolBar("Main")
+ tb.setMovable(False)
+ tb.setIconSize(QSize(18, 18))
+ tb.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon)
+ self.addToolBar(tb)
+ tb.addActions([open_act, rescan_act, save_act])
+ tb.addSeparator()
+ tb.addActions([add_act, reorg_act])
+ tb.addSeparator()
+ tb.addAction(rules_act)
+ rules_btn = tb.widgetForAction(rules_act)
+ rules_btn.setMenu(options_menu)
+ rules_btn.setPopupMode(QToolButton.ToolButtonPopupMode.MenuButtonPopup)
+ tb.addActions([lrc_act, art_fetch_act])
+
+ self.table.extra_actions = self.art_actions
+ selection = self.table.selectionModel()
+ selection.selectionChanged.connect(self._on_selection)
+ selection.currentRowChanged.connect(self._on_selection)
+
+ self.search = QLineEdit(placeholderText="Filter by any field…", clearButtonEnabled=True)
+ self.search.textChanged.connect(lambda t: self.table.proxy.set_filter(text=t))
+ self.missing_lrc = QCheckBox("Missing LRC")
+ self.missing_lrc.toggled.connect(lambda c: self.table.proxy.set_filter(missing_lrc=c))
+ self.missing_art = QCheckBox("Missing art")
+ self.missing_art.toggled.connect(lambda c: self.table.proxy.set_filter(missing_art=c))
+ filters = QHBoxLayout()
+ filters.addWidget(self.search, 1)
+ filters.addWidget(QLabel("Show only:"))
+ filters.addWidget(self.missing_lrc)
+ filters.addWidget(self.missing_art)
+
+ self.log_view = QPlainTextEdit(readOnly=True)
+ self.log_view.setMaximumBlockCount(10000)
+ log_box = QWidget()
+ log_layout = QVBoxLayout(log_box)
+ log_layout.setContentsMargins(0, 0, 0, 0)
+ log_layout.addWidget(section_label("Log"))
+ log_layout.addWidget(self.log_view)
+ self.art_panel = ArtPanel(self.art_actions)
+
+ bottom = QSplitter(Qt.Orientation.Horizontal)
+ bottom.addWidget(log_box)
+ bottom.addWidget(self.art_panel)
+ bottom.setStretchFactor(0, 1)
+ bottom.setSizes([1000, 280])
+
+ splitter = QSplitter(Qt.Orientation.Vertical)
+ splitter.addWidget(self.table)
+ splitter.addWidget(bottom)
+ splitter.setSizes([550, 280])
+
+ self.progress = ProgressStrip(self.stop_action)
+
+ central = QWidget()
+ layout = QVBoxLayout(central)
+ layout.addLayout(filters)
+ layout.addWidget(self.progress)
+ layout.addWidget(splitter, 1)
+ self.setCentralWidget(central)
+ self._on_selection()
+
+ self.status = QLabel()
+ self.statusBar().addWidget(self.status, 1)
+
+ missing = [tool for tool in REQUIRED_TOOLS if not shutil.which(tool)]
+ if missing:
+ self.log(f"WARNING: not found on PATH: {', '.join(missing)} · applying rules will fail.")
+
+ self._update_status()
+ if folder:
+ self.load_folder(folder)
+
+ def log(self, text: str):
+ self.log_view.appendPlainText(text)
+
+ def worker_count(self) -> int:
+ return self.worker_group.checkedAction().data()
+
+ def _on_selection(self, *_):
+ tracks = self.table.selected_tracks()
+ for act in self.art_actions:
+ act.setEnabled(bool(tracks) and not self.worker)
+ self.art_panel.show_track(self.table.current_track() if tracks else None, len(tracks))
+
+ def _update_status(self, *_):
+ dirty = sum(t.is_dirty() for t in self.model.tracks)
+ missing = sum(t.lrc != "OK" for t in self.model.tracks)
+ no_art = sum(not t.art for t in self.model.tracks)
+ where = str(self.folder) if self.folder else "No folder open"
+ self.status.setText(f"{where} · {len(self.model.tracks)} tracks · {missing} missing LRC · "
+ f"{no_art} missing art · {dirty} unsaved")
+ self.setWindowTitle(("* " if dirty else "") + "expergo")
+
+ def run_worker(self, title, items, fn, on_done=None, workers=None):
+ self.worker = Worker(items, fn, workers or self.worker_count(), self)
+ self.worker.log.connect(self.log)
+ self.worker.progress.connect(self.progress.set_progress)
+ self.worker.current.connect(lambda name: self.progress.set_detail(name))
+ self.worker.finished.connect(lambda: self._on_worker_done(on_done))
+ self._set_busy(True)
+ self.progress.start(title, len(self.worker.items))
+ self.worker.start()
+
+ def _on_worker_done(self, on_done):
+ worker, self.worker = self.worker, None
+ self.progress.finish()
+ self._set_busy(False)
+ if on_done:
+ on_done(worker)
+
+ def _set_busy(self, busy: bool):
+ for act in self.busy_actions:
+ act.setEnabled(not busy)
+ self.stop_action.setEnabled(busy)
+ for act in self.art_actions:
+ act.setEnabled(not busy and bool(self.table.selected_tracks()))
+ self.table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers if busy else EDIT_TRIGGERS)
+
+ def _confirm_discard(self) -> bool:
+ if not any(t.is_dirty() for t in self.model.tracks):
+ return True
+ answer = QMessageBox.question(self, "Unsaved changes", "Discard unsaved tag edits?")
+ return answer == QMessageBox.StandardButton.Yes
+
+ def _pick_folder(self):
+ folder = QFileDialog.getExistingDirectory(self, "Open music folder", str(self.folder or ""))
+ if folder:
+ self.load_folder(Path(folder))
+
+ def load_folder(self, folder: Path):
+ if not self._confirm_discard():
+ return
+ self.folder = Path(folder).resolve()
+ files = find_flacs([self.folder])
+ self.log(f"Scanning {self.folder} ({len(files)} FLAC files)…")
+
+ def done(worker):
+ self.model.set_tracks(sorted((t for _, t in worker.results), key=lambda t: str(t.path).lower()))
+ self.table.fit_columns()
+
+ self.run_worker("Scanning library", files, Track.load, done, workers=4)
+
+ def rescan(self):
+ if self.folder:
+ self.load_folder(self.folder)
+
+ def save_changes(self, tracks=None) -> bool:
+ ok = True
+ for track in [t for t in (tracks or self.model.tracks) if t.is_dirty()]:
+ try:
+ save_track(track)
+ self.log(f"Saved {track.name}")
+ except Exception as e:
+ ok = False
+ self.log(f"ERROR saving {track.name}: {e}")
+ self.model.refresh()
+ return ok
+
+ def process(self):
+ tracks = self.table.target_tracks()
+ if not tracks or not tools_ok(self):
+ return
+ answer = QMessageBox.question(
+ self,
+ "Apply Echo rules",
+ f"Apply Echo formatting rules to {len(tracks)} file(s)?\n\n"
+ "Files are renamed, re-encoded, loudness-normalized and have their album art resized IN PLACE.\n"
+ "Unsaved edits on these rows are saved first.",
+ )
+ if answer != QMessageBox.StandardButton.Yes or not self.save_changes(tracks):
+ return
+ core._claimed_names.clear()
+ nolrc = self.nolrc_action.isChecked()
+ self.run_worker("Applying Echo rules", [t.path for t in tracks], lambda p: core.process_file(p, nolrc),
+ lambda _: self.rescan())
+
+ def fetch_missing_lrc(self):
+ tracks = [t for t in self.table.target_tracks() if t.lrc != "OK"]
+ if not tracks:
+ self.log("No missing LRC files in the current selection.")
+ return
+
+ def done(_):
+ for t in tracks:
+ t.refresh_lrc()
+ self.model.refresh()
+ self.table.proxy.invalidate()
+
+ self.log(f"Fetching LRC for {len(tracks)} track(s)…")
+ self.run_worker("Fetching lyrics", tracks, fetch_lrc, done)
+
+ def _refresh_art(self, tracks):
+ for t in tracks:
+ t.refresh_art()
+ self.model.refresh()
+ self.table.proxy.invalidate()
+ self._on_selection()
+
+ def fetch_missing_art(self):
+ groups: dict[tuple[str, str], list[Track]] = {}
+ skipped = 0
+ for t in self.table.target_tracks():
+ if t.art:
+ continue
+ if not t.album_key[1]:
+ skipped += 1
+ continue
+ groups.setdefault(t.album_key, []).append(t)
+ if skipped:
+ self.log(f"Skipped {skipped} track(s) without an Album tag (use Edit Art… for those)")
+ if not groups:
+ self.log("No tracks with missing album art in the current selection.")
+ return
+ items = [
+ SimpleNamespace(key=key, tracks=tracks, name=f"{key[1]} — {key[0]}" if key[0] else key[1])
+ for key, tracks in groups.items()
+ ]
+ tracks = [t for group in items for t in group.tracks]
+ self.log(f"Fetching album art for {len(items)} album(s) ({len(tracks)} tracks)…")
+ self.run_worker("Fetching album art", items, auto_cover, lambda _: self._refresh_art(tracks))
+
+ def edit_cover(self):
+ tracks = self.table.selected_tracks()
+ if not tracks:
+ return
+ first = self.table.current_track() or tracks[0]
+ dialog = CoverDialog(first.tags["ALBUM"], first.tags["ALBUMARTIST"] or first.tags["ARTIST"], len(tracks), self)
+ if dialog.exec() and dialog.source:
+ source = dialog.source
+ self.log(f"Applying album art ({source[0]}: {source[1]}) to {len(tracks)} track(s)…")
+ self.run_worker("Applying album art", [source], lambda s: apply_cover(s, tracks),
+ lambda _: self._refresh_art(tracks), workers=1)
+ origin = "Cover Art Archive" if source[0] == "mbid" else Path(source[1]).name
+ self.progress.set_detail(f"Downloading from {origin} and embedding into {len(tracks)} track(s)")
+ dialog.deleteLater()
+
+ def remove_cover(self):
+ tracks = self.table.selected_tracks()
+ if not tracks:
+ return
+ answer = QMessageBox.question(self, "Remove album art", f"Remove embedded album art from {len(tracks)} track(s)?")
+ if answer != QMessageBox.StandardButton.Yes:
+ return
+ def remove(track):
+ covers.write_cover(track.path, None)
+ return f"Removed album art from {track.name}"
+
+ self.run_worker("Removing album art", tracks, remove, lambda _: self._refresh_art(tracks))
+
+ def reorganize(self):
+ if not self.folder or not self.model.tracks:
+ self.log("Open a library folder first.")
+ return
+ if any(t.is_dirty() for t in self.model.tracks):
+ answer = QMessageBox.question(
+ self, "Reorganize", "Folders are based on the tags saved on disk. Save your unsaved edits first?",
+ QMessageBox.StandardButton.Save | QMessageBox.StandardButton.Ignore | QMessageBox.StandardButton.Cancel,
+ )
+ if answer == QMessageBox.StandardButton.Cancel:
+ return
+ if answer == QMessageBox.StandardButton.Save and not self.save_changes():
+ return
+ dialog = ReorganizeDialog(self.model.tracks, self.folder, self)
+ accepted = dialog.exec()
+ moves, cleanup = dialog.moves, dialog.cleanup.isChecked()
+ dialog.deleteLater()
+ if not accepted or not moves:
+ return
+
+ library_root = self.folder
+
+ def done(worker):
+ moved = len(worker.results)
+ msg = f"Moved {moved} of {len(moves)} file(s) into Artist / Album folders"
+ if cleanup and library_root:
+ msg += f", removed {remove_empty_dirs(library_root)} empty folder(s)"
+ self.log(msg)
+ self.rescan()
+
+ self.log(f"Reorganizing {len(moves)} file(s)…")
+ self.run_worker("Reorganizing folders", moves, move_track, done, workers=1)
+
+ def add_songs(self):
+ dialog = ImportDialog(self.folder, self.worker_count(), self.nolrc_action.isChecked(), self)
+ dialog.imported.connect(self.rescan)
+ dialog.exec()
+ dialog.deleteLater()
+
+ def closeEvent(self, event):
+ if self.worker:
+ QMessageBox.information(self, "Busy", "A task is still running. Stop it first.")
+ event.ignore()
+ elif self._confirm_discard():
+ event.accept()
+ else:
+ event.ignore()
+
+
+def main():
+ app = QApplication(sys.argv)
+ window = MainWindow(Path(sys.argv[1]) if len(sys.argv) > 1 else None)
+ window.show()
+ sys.exit(app.exec())
+
+
+if __name__ == "__main__":
+ main()
send patches to the email below
yukais@pinapelz.com
include the subject [PATCH repo_name]
pinapelz.com
homepage