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, QUrl, 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, QSlider, QSpinBox, QSplitter, QStyle, QTableView, QTableWidget, QTableWidgetItem, QToolBar, QToolButton, QVBoxLayout, QWidget, ) from PyQt6.QtMultimedia import QAudioOutput, QMediaPlayer 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, track_number, 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(placeholder-text);") # 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, replaygain: bool = False, 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())) mb_info = QPushButton("MusicBrainz Album Info…") mb_info.clicked.connect(self._musicbrainz_album_info) top = QHBoxLayout() for w in (add_files, add_folder, remove, mb_info): 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.replaygain = QCheckBox("Use ReplayGain") self.replaygain.setChecked(replaygain) self.replaygain.setToolTip("Write ReplayGain tags instead of destructively normalizing the audio.\n" "Only effective if your player supports ReplayGain.") self.replaygain.setEnabled(self.apply_rules.isChecked()) self.apply_rules.toggled.connect(self.replaygain.setEnabled) 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.addWidget(self.replaygain) 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 _musicbrainz_album_info(self): tracks = self.table.selected_tracks() or self.model.tracks if not tracks: QMessageBox.information(self, "Add Songs", "Add at least one track first.") return first = self.table.current_track() or tracks[0] dialog = AlbumInfoDialog(first.tags["ALBUM"], (first.tags["ALBUMARTIST"] or first.tags["ARTIST"]), len(tracks), self) accepted = dialog.exec() info = dialog.result_info dialog.deleteLater() if not accepted or not info: return album, album_artist = info["album"].strip(), info["album_artist"].strip() by_number, tagged, unmatched = info["by_number"], 0, [] for track in tracks: if album: track.tags["ALBUM"] = album if album_artist: track.tags["ALBUMARTIST"] = album_artist if not info["tag_tracks"]: continue number = track_number(track.path) match = by_number.get(number) if number else None if match: title, artist = match if title: track.tags["TITLE"] = title if artist: track.tags["ARTIST"] = artist tagged += 1 else: unmatched.append(track.name) self.model.refresh() self.log.appendPlainText(f"Applied album info from MusicBrainz ({info['mbid']}) to {len(tracks)} track(s): " f"ALBUM='{album}', ALBUMARTIST='{album_artist}'") if info["tag_tracks"]: self.log.appendPlainText(f" Tagged {tagged} title(s)/artist(s) by track number") for name in unmatched: self.log.appendPlainText(f" No tracklist match for {name} (missing/unknown track number)") def _import_one(self, track: Track, dest_root: Path, layout: FolderLayout | None, apply_rules: bool, nolrc: bool, replaygain: 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, replaygain) 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, replaygain = (self.apply_rules.isChecked(), self.nolrc.isChecked(), self.replaygain.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, replaygain) 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 AlbumInfoDialog(QDialog): def __init__(self, album: str, artist: str, count: int, parent=None): super().__init__(parent) self.setWindowTitle(f"Album Info · {count} track(s)") self.resize(900, 640) self.result_info: dict | None = None self._task: Task | None = None self._releases: list[dict] = [] 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.setAlternatingRowColors(True) self.results.itemDoubleClicked.connect(self._apply) self.results.currentItemChanged.connect(self._on_release_changed) self.tracks = QTableWidget(0, 3) self.tracks.setHorizontalHeaderLabels(["#", "Title", "Artist"]) self.tracks.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) self.tracks.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents) self.tracks.verticalHeader().setVisible(False) self.tracks.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) self.tracks.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) lists = QSplitter(Qt.Orientation.Horizontal) releases_box = QWidget() releases_layout = QVBoxLayout(releases_box) releases_layout.setContentsMargins(0, 0, 0, 0) releases_layout.addWidget(section_label("Releases")) releases_layout.addWidget(self.results) tracks_box = QWidget() tracks_layout = QVBoxLayout(tracks_box) tracks_layout.setContentsMargins(0, 0, 0, 0) tracks_layout.addWidget(section_label("Tracklist")) tracks_layout.addWidget(self.tracks) lists.addWidget(releases_box) lists.addWidget(tracks_box) lists.setSizes([380, 520]) self.tag_titles = QCheckBox("Tag titles / artists by track number") self.tag_titles.setChecked(True) self.tag_titles.setToolTip("Match each row's track number against the MusicBrainz tracklist\n" "and fill in TITLE and ARTIST") self.status = QLabel() 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(self.tag_titles) bottom.addWidget(self.status, 1) bottom.addWidget(self.apply_btn) bottom.addWidget(cancel) self.progress = ProgressStrip() layout = QVBoxLayout(self) layout.addLayout(top) layout.addWidget(self.progress) layout.addWidget(lists, 1) layout.addLayout(bottom) 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.tracks.setRowCount(0) self._releases = [] self.status.clear() self.apply_btn.setEnabled(False) self.progress.start("Searching MusicBrainz") self.progress.set_detail(f"{album} - {artist}" if artist else album) def run(_task): return covers.search_releases(album, artist) self._task = Task(run) self._task.done.connect(self._on_results) self._task.failed.connect(self._on_failed) self._task.start() def _stale(self) -> bool: return self.sender() is not self._task def _on_results(self, releases): if self._stale(): return self.progress.finish() self._releases = list(releases) for rel in self._releases: label = f"{rel['title']} — {rel['artist']}" item = QListWidgetItem(f"{label}\n{rel['details']}" if rel["details"] else label) item.setToolTip(f"{label}\n{rel['details']}\nMBID: {rel['id']}") self.results.addItem(item) if self._releases: self.status.setText(f"{len(self._releases)} release(s) found.") self.results.setCurrentRow(0) else: self.status.setText("No releases found.") def _on_failed(self, message): if not self._stale(): self.progress.finish() self.status.setText(f"Search failed: {message}") def _current_release(self) -> dict | None: row = self.results.currentRow() return self._releases[row] if 0 <= row < len(self._releases) else None def _on_release_changed(self, *_): release = self._current_release() self.tracks.setRowCount(0) self.apply_btn.setEnabled(bool(release)) if not release: return self.progress.start("Loading tracklist") self.progress.set_detail(release["title"]) mbid = release["id"] def run(_task): return mbid, covers.fetch_release_tracks(mbid) self._task = Task(run) self._task.done.connect(self._on_tracks) self._task.failed.connect(self._on_failed) self._task.start() def _on_tracks(self, payload): if self._stale(): return self.progress.finish() mbid, tracks = payload release = self._current_release() if not release or release["id"] != mbid: return self.tracks.setRowCount(len(tracks)) for row, track in enumerate(tracks): number = f"{track['disc']}-{track['number']}" if track["disc"] > 1 else str(track["number"]) for col, text in enumerate((number, track["title"], track["artist"])): self.tracks.setItem(row, col, QTableWidgetItem(text)) self.status.setText(f"{len(tracks)} track(s) in {release['title']}.") def _apply(self, *_): release = self._current_release() if not release: return by_number: dict[int, tuple[str, str]] = {} for row in range(self.tracks.rowCount()): number_item = self.tracks.item(row, 0) title_item = self.tracks.item(row, 1) artist_item = self.tracks.item(row, 2) if not number_item: continue number = number_item.text().split("-")[-1] if number.isdigit(): by_number[int(number)] = (title_item.text() if title_item else "", artist_item.text() if artist_item else "") self.result_info = { "album": release["title"], "album_artist": release["artist"], "mbid": release["id"], "tag_tracks": self.tag_titles.isChecked(), "by_number": by_number, } self.accept() class RipCdDialog(QDialog): def __init__(self, drives: list[tuple[str, str]], parent=None): super().__init__(parent) self.setWindowTitle("Select optical drive") self.resize(680, 360) self.selected_drive: str | None = None layout = QVBoxLayout(self) layout.addWidget(QLabel("Select the optical drive to use for CD ripping:")) self.drives = QListWidget() for path, label in drives: item = QListWidgetItem(label) item.setData(Qt.ItemDataRole.UserRole, path) self.drives.addItem(item) if drives: self.drives.setCurrentRow(0) self.drives.itemDoubleClicked.connect(lambda _: self._accept()) layout.addWidget(self.drives, 1) buttons = QHBoxLayout() buttons.addStretch(1) ok_btn = QPushButton("Use Drive") ok_btn.clicked.connect(self._accept) cancel_btn = QPushButton("Cancel") cancel_btn.clicked.connect(self.reject) buttons.addWidget(ok_btn) buttons.addWidget(cancel_btn) layout.addLayout(buttons) def _accept(self): item = self.drives.currentItem() if not item: QMessageBox.information(self, "No drive selected", "Select an optical drive first.") return self.selected_drive = item.data(Qt.ItemDataRole.UserRole) self.accept() 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 {root} into Artist / Album 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(placeholder-text); 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(placeholder-text);") 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 AudioPlayerPanel(QFrame): def __init__(self, logger=None, parent=None): super().__init__(parent) self.setFrameShape(QFrame.Shape.StyledPanel) self._log = logger or (lambda _msg: None) self.player = QMediaPlayer(self) self.audio = QAudioOutput(self) self.audio.setMuted(False) self.audio.setVolume(1.0) self.player.setAudioOutput(self.audio) self._duration = 0 self._current_path: Path | None = None self.title = QLabel("No track loaded") self.title.setStyleSheet("font-weight: bold;") self.time = QLabel("0:00 / 0:00") self.slider = QSlider(Qt.Orientation.Horizontal) self.slider.setRange(0, 0) self.play_btn = QToolButton() self.play_btn.setIcon(std_icon("media-playback-start", QStyle.StandardPixmap.SP_MediaPlay)) self.play_btn.setToolTip("Play / Pause") self.stop_btn = QToolButton() self.stop_btn.setIcon(std_icon("media-playback-stop", QStyle.StandardPixmap.SP_MediaStop)) self.stop_btn.setToolTip("Stop") row = QHBoxLayout(self) row.setContentsMargins(8, 4, 8, 4) row.addWidget(section_label("Preview")) row.addWidget(self.title, 1) row.addWidget(self.play_btn) row.addWidget(self.stop_btn) row.addWidget(self.time) row.addWidget(self.slider, 2) self.play_btn.clicked.connect(self.toggle_play_pause) self.stop_btn.clicked.connect(self.stop) self.slider.sliderMoved.connect(self.player.setPosition) self.player.positionChanged.connect(self._on_position) self.player.durationChanged.connect(self._on_duration) self.player.playbackStateChanged.connect(self._on_state) self.player.errorOccurred.connect(self._on_error) self.player.mediaStatusChanged.connect(self._on_media_status) def current_path(self) -> Path | None: return self._current_path def load(self, path: Path, autoplay: bool = True): self._current_path = path self.title.setText(path.name) self.audio.setMuted(False) self.player.stop() self.player.setSource(QUrl.fromLocalFile(str(path))) self.player.setPosition(0) if autoplay: self.player.play() def toggle_play_pause(self): if self.player.playbackState() == QMediaPlayer.PlaybackState.PlayingState: self.player.pause() else: self.player.play() def stop(self): self.player.stop() def clear_if_matches(self, path: Path): if self._current_path and self._current_path == path: self.stop() self._current_path = None self.title.setText("No track loaded") self._duration = 0 self.slider.setRange(0, 0) self.time.setText("0:00 / 0:00") def _on_position(self, pos: int): if not self.slider.isSliderDown(): self.slider.setValue(pos) self.time.setText(f"{format_duration(pos / 1000)} / {format_duration(self._duration / 1000)}") def _on_duration(self, ms: int): self._duration = max(0, ms) self.slider.setRange(0, self._duration) self.time.setText(f"{format_duration(self.player.position() / 1000)} / {format_duration(self._duration / 1000)}") def _on_state(self, state): icon = ("media-playback-pause", QStyle.StandardPixmap.SP_MediaPause) if state == QMediaPlayer.PlaybackState.PlayingState else ("media-playback-start", QStyle.StandardPixmap.SP_MediaPlay) self.play_btn.setIcon(std_icon(*icon)) def _on_media_status(self, status): if status == QMediaPlayer.MediaStatus.InvalidMedia and self._current_path: self._log(f"Preview failed: unsupported/unreadable media: {self._current_path}") def _on_error(self, _error, error_text: str): if error_text: self.title.setText(f"Preview error: {error_text}") self._log(f"Preview error: {error_text}") 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.rip_task: Task | None = None self.rip_drive: str | 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)") rip_cd_act = action("Rip CD", self.rip_cd, ("media-optical", SP.SP_DirOpenIcon), tip="Rip tracks from an audio CD") self.preview_action = action("Preview", self.preview_track, ("media-playback-start", SP.SP_MediaPlay), tip="Play/pause the selected track in the embedded player") 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.delete_action = action("Delete File(s)…", self.delete_selected_tracks, ("edit-delete", SP.SP_TrashIcon), "Delete", "Delete selected audio files from disk and remove them from the table") 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.replaygain_action = action("Use ReplayGain Instead of Normalizing", None, checkable=True, tip="Write ReplayGain tags instead of destructively normalizing the audio\n" "(only effective if your player supports ReplayGain)") 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, rip_cd_act, rules_act, lrc_act, art_fetch_act, self.preview_action, self.delete_action] 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, rip_cd_act]) file_menu.addSeparator() file_menu.addAction(action("Quit", self.close, shortcut="Ctrl+Q")) tracks_menu = menubar.addMenu("&Tracks") tracks_menu.addAction(self.preview_action) tracks_menu.addAction(self.delete_action) tracks_menu.addSeparator() 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.addAction(self.replaygain_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, rip_cd_act]) tb.addSeparator() tb.addAction(self.preview_action) tb.addAction(self.delete_action) 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.preview_action, self.delete_action, self.art_edit_action] 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) self.player_panel = AudioPlayerPanel(self.log, self) central = QWidget() layout = QVBoxLayout(central) layout.addLayout(filters) layout.addWidget(self.progress) layout.addWidget(self.player_panel) 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() enabled = bool(tracks) and not self.worker self.preview_action.setEnabled(enabled) self.delete_action.setEnabled(enabled) for act in self.art_actions: act.setEnabled(enabled) 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) selected = bool(self.table.selected_tracks()) self.preview_action.setEnabled(not busy and selected) self.delete_action.setEnabled(not busy and selected) for act in self.art_actions: act.setEnabled(not busy and selected) 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 _selected_source_rows(self) -> list[int]: rows = self.table.selected_rows() if not rows and self.table.currentIndex().isValid(): rows = [self.table.proxy.mapToSource(self.table.currentIndex()).row()] return rows def delete_selected_tracks(self): rows = self._selected_source_rows() if not rows: self.log("Select at least one track to delete.") return tracks = [self.model.tracks[r] for r in rows] answer = QMessageBox.warning( self, "Delete file(s)", f"Delete {len(tracks)} file(s) from disk?\n\n" "This permanently deletes the audio files and any sidecar .lrc files.", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel, QMessageBox.StandardButton.Cancel, ) if answer != QMessageBox.StandardButton.Yes: return removed_rows: list[int] = [] errors: list[str] = [] removed_files = removed_lrc = 0 for row, track in zip(rows, tracks): path = track.path try: if path.exists(): if not path.is_file(): raise RuntimeError("path is not a file") path.unlink() removed_files += 1 lrc_path = path.with_suffix(".lrc") if lrc_path.exists() and lrc_path.is_file(): lrc_path.unlink() removed_lrc += 1 removed_rows.append(row) self.player_panel.clear_if_matches(path) except Exception as e: errors.append(f"{track.name}: {e}") if removed_rows: self.model.remove_rows(removed_rows) cleaned = remove_empty_dirs(self.folder) if self.folder else 0 self.log(f"Deleted {len(removed_rows)} track(s) from table ({removed_files} audio, {removed_lrc} LRC), removed {cleaned} empty folder(s).") self._update_status() self._on_selection() if errors: for err in errors: self.log(f"ERROR deleting {err}") QMessageBox.warning(self, "Delete finished with errors", "\n".join(errors[:8])) def preview_track(self): track = self.table.current_track() or (self.table.selected_tracks()[0] if self.table.selected_tracks() else None) if not track: self.log("Select a track to preview.") return if not track.path.exists(): self.log(f"Preview failed: file does not exist: {track.path}") return if self.player_panel.current_path() == track.path: self.player_panel.toggle_play_pause() else: self.player_panel.load(track.path, autoplay=True) def process(self): tracks = self.table.target_tracks() if not tracks or not tools_ok(self): return replaygain = self.replaygain_action.isChecked() loudness = "tagged with ReplayGain" if replaygain else "loudness-normalized" answer = QMessageBox.question( self, "Apply Echo rules", f"Apply Echo formatting rules to {len(tracks)} file(s)?\n\n" f"Files are renamed, re-encoded, {loudness} 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, replaygain), 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.replaygain_action.isChecked(), self) dialog.imported.connect(self.rescan) dialog.exec() dialog.deleteLater() def rip_cd(self): import optical if self.worker or self.rip_task: QMessageBox.information(self, "Busy", "A task is still running. Stop it first.") return ok, msg = optical.check_dependencies() if not ok: if optical.needs_cuetools_install(): answer = QMessageBox.question( self, "Rip CD", ( "CUERipper was not found.\n\n" "Download and install CUETools now?\n\n" f"Source: {optical.CUETOOLS_DOWNLOAD_URL}" ), QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.Yes, ) if answer == QMessageBox.StandardButton.Yes: try: optical.ensure_windows_cuetools(on_output=self.log) ok, msg = optical.check_dependencies() except Exception as err: QMessageBox.critical(self, "Rip CD", f"Failed to install CUETools.\n\n{err}") return if not ok: QMessageBox.critical(self, "Rip CD", msg) return drives = optical.get_optical_drives() if not drives: QMessageBox.information(self, "Rip CD", "No optical drives found.") self.log("Rip CD: no optical drives found.") return dialog = RipCdDialog(drives, self) accepted = dialog.exec() selected_drive = dialog.selected_drive dialog.deleteLater() if not accepted or not selected_drive: return self.rip_drive = selected_drive self.log(f"Rip CD selected drive: {self.rip_drive}") self.log("Starting CD ripping process…") self._set_busy(True) self.stop_action.setEnabled(False) self.progress.start("Ripping CD", 1) self.progress.set_detail(f"Ripping from {selected_drive}, Don't worry if the metadata is off you'll have a chance to fix it") def run(task): return optical.run_abcde_rip( selected_drive, on_output=lambda line: task.progress.emit(line), ) self.rip_task = Task(run) self.rip_task.progress.connect(lambda line: self.log(str(line))) def done(flac_files): self.rip_task = None self.progress.finish() self._set_busy(False) if flac_files: self.log(f"Rip complete: {len(flac_files)} FLAC file(s).") for path in flac_files: self.log(path) dialog = ImportDialog(self.folder, self.worker_count(), self.nolrc_action.isChecked(), self.replaygain_action.isChecked(), self) dialog.imported.connect(self.rescan) dialog.add_paths(flac_files) dialog.exec() dialog.deleteLater() else: self.log("Rip complete: no FLAC files were found in output.") def failed(err): self.rip_task = None self.progress.finish() self._set_busy(False) self.log(f"ERROR ripping CD: {err}") QMessageBox.critical(self, "Rip CD", f"Ripping failed.\n\n{err}") self.rip_task.done.connect(done) self.rip_task.failed.connect(failed) self.rip_task.start() def closeEvent(self, event): if self.worker or self.rip_task: QMessageBox.information(self, "Busy", "A task is still running. Stop it first.") event.ignore() elif self._confirm_discard(): self.player_panel.stop() 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()