aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--dialogs/__init__.py13
-rw-r--r--dialogs/album_info_dialog.py204
-rw-r--r--dialogs/cover_dialog.py159
-rw-r--r--dialogs/import_dialog.py279
-rw-r--r--dialogs/reorganize_dialog.py97
-rw-r--r--dialogs/rip_cd_dialog.py50
-rw-r--r--gui.py1104
-rw-r--r--ui_common.py396
8 files changed, 1214 insertions, 1088 deletions
diff --git a/dialogs/__init__.py b/dialogs/__init__.py
new file mode 100644
index 0000000..f318828
--- /dev/null
+++ b/dialogs/__init__.py
@@ -0,0 +1,13 @@
+from .album_info_dialog import AlbumInfoDialog
+from .cover_dialog import CoverDialog
+from .import_dialog import ImportDialog
+from .reorganize_dialog import ReorganizeDialog
+from .rip_cd_dialog import RipCdDialog
+
+__all__ = [
+ "AlbumInfoDialog",
+ "CoverDialog",
+ "ImportDialog",
+ "ReorganizeDialog",
+ "RipCdDialog",
+]
diff --git a/dialogs/album_info_dialog.py b/dialogs/album_info_dialog.py
new file mode 100644
index 0000000..7ed5833
--- /dev/null
+++ b/dialogs/album_info_dialog.py
@@ -0,0 +1,204 @@
+from PyQt6.QtCore import Qt
+from PyQt6.QtWidgets import (
+ QAbstractItemView,
+ QCheckBox,
+ QDialog,
+ QHBoxLayout,
+ QHeaderView,
+ QLabel,
+ QLineEdit,
+ QListWidget,
+ QListWidgetItem,
+ QPushButton,
+ QSplitter,
+ QTableWidget,
+ QTableWidgetItem,
+ QVBoxLayout,
+ QWidget,
+)
+
+import covers
+from ui_common import ProgressStrip, Task, section_label
+
+
+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()
diff --git a/dialogs/cover_dialog.py b/dialogs/cover_dialog.py
new file mode 100644
index 0000000..a1a07ca
--- /dev/null
+++ b/dialogs/cover_dialog.py
@@ -0,0 +1,159 @@
+from PyQt6.QtCore import QSize, Qt
+from PyQt6.QtGui import QColor, QIcon, QPixmap
+from PyQt6.QtWidgets import (
+ QDialog,
+ QFileDialog,
+ QHBoxLayout,
+ QLabel,
+ QLineEdit,
+ QListView,
+ QListWidget,
+ QListWidgetItem,
+ QPushButton,
+ QVBoxLayout,
+)
+
+import covers
+from ui_common import ProgressStrip, Task
+
+
+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()
diff --git a/dialogs/import_dialog.py b/dialogs/import_dialog.py
new file mode 100644
index 0000000..b23e9a1
--- /dev/null
+++ b/dialogs/import_dialog.py
@@ -0,0 +1,279 @@
+import shutil
+import threading
+from pathlib import Path
+
+from PyQt6.QtCore import Qt, pyqtSignal
+from PyQt6.QtGui import QAction
+from PyQt6.QtWidgets import (
+ QCheckBox,
+ QDialog,
+ QFileDialog,
+ QHBoxLayout,
+ QLabel,
+ QLineEdit,
+ QMessageBox,
+ QPlainTextEdit,
+ QPushButton,
+ QSpinBox,
+ QSplitter,
+ QStyle,
+ QVBoxLayout,
+)
+
+import expergo as core
+from models import (
+ FolderLayout,
+ Track,
+ find_flacs,
+ flac_name,
+ track_number,
+ write_tags,
+)
+from ui_common import ProgressStrip, TrackModel, TrackTable, Worker, std_icon, tools_ok
+
+from .album_info_dialog import AlbumInfoDialog
+
+
+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()
diff --git a/dialogs/reorganize_dialog.py b/dialogs/reorganize_dialog.py
new file mode 100644
index 0000000..1385c98
--- /dev/null
+++ b/dialogs/reorganize_dialog.py
@@ -0,0 +1,97 @@
+from pathlib import Path
+from types import SimpleNamespace
+
+from PyQt6.QtWidgets import (
+ QAbstractItemView,
+ QCheckBox,
+ QDialog,
+ QHBoxLayout,
+ QHeaderView,
+ QLabel,
+ QPushButton,
+ QTableWidget,
+ QTableWidgetItem,
+ QVBoxLayout,
+)
+
+from models import Track, plan_reorganize
+from ui_common import MISSING_BRUSH
+
+
+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))
diff --git a/dialogs/rip_cd_dialog.py b/dialogs/rip_cd_dialog.py
new file mode 100644
index 0000000..dd3ac8f
--- /dev/null
+++ b/dialogs/rip_cd_dialog.py
@@ -0,0 +1,50 @@
+from PyQt6.QtCore import Qt
+from PyQt6.QtWidgets import (
+ QDialog,
+ QHBoxLayout,
+ QLabel,
+ QListWidget,
+ QListWidgetItem,
+ QMessageBox,
+ QPushButton,
+ QVBoxLayout,
+)
+
+
+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()
diff --git a/gui.py b/gui.py
index b8e0509..c4fc154 100644
--- a/gui.py
+++ b/gui.py
@@ -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__()
diff --git a/ui_common.py b/ui_common.py
new file mode 100644
index 0000000..f85a7cb
--- /dev/null
+++ b/ui_common.py
@@ -0,0 +1,396 @@
+import shutil
+import subprocess
+from concurrent.futures import ThreadPoolExecutor, as_completed
+
+from PyQt6.QtCore import (
+ QAbstractTableModel,
+ QElapsedTimer,
+ QModelIndex,
+ QSortFilterProxyModel,
+ Qt,
+ QThread,
+ QTimer,
+ pyqtSignal,
+)
+from PyQt6.QtGui import QAction, QBrush, QColor, QIcon
+from PyQt6.QtWidgets import (
+ QAbstractItemView,
+ QApplication,
+ QFrame,
+ QHBoxLayout,
+ QInputDialog,
+ QLabel,
+ QMenu,
+ QMessageBox,
+ QProgressBar,
+ QSizePolicy,
+ QStyle,
+ QTableView,
+ QToolButton,
+)
+
+from models import ART_COL, FIELDS, HEADERS, LRC_COL, Track
+
+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
+)
+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()
+
+
+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
send patches to the email below
yukais@pinapelz.com
include the subject [PATCH repo_name]
pinapelz.com
homepage