diff options
| author | Pinapelz <yukais@pinapelz.com> | 2026-10-30 13:46:12 -0700 |
|---|---|---|
| committer | Pinapelz <yukais@pinapelz.com> | 2026-10-30 13:46:12 -0700 |
| commit | ca7cb853bdf289f9e0776fe8ac9d517ca744e204 (patch) | |
| tree | f50645520f9474ab0b54e499c4f83a38115bfeec /gui.py | |
| parent | c26370de18bd892df5c22a81f9b613a07bb67638 (diff) | |
chore(gui): refactor dialogs into their own modules
Diffstat (limited to 'gui.py')
| -rw-r--r-- | gui.py | 1104 |
1 files changed, 16 insertions, 1088 deletions
@@ -1,53 +1,28 @@ 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.QtCore import QSize, Qt, QUrl +from PyQt6.QtGui import QAction, QActionGroup, 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, @@ -58,937 +33,32 @@ from PyQt6.QtMultimedia import QAudioOutput, QMediaPlayer import covers import expergo as core +from dialogs import CoverDialog, ImportDialog, ReorganizeDialog, RipCdDialog 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 +from ui_common import ( + EDIT_TRIGGERS, + REQUIRED_TOOLS, + ProgressStrip, + Task, + TrackModel, + TrackTable, + Worker, + format_duration, + section_label, + std_icon, + tools_ok, ) 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 <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(placeholder-text); padding: 2px 0;") - return label class ArtPanel(QWidget): @@ -1164,148 +234,6 @@ class AudioPlayerPanel(QFrame): 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__() |
