aboutsummaryrefslogtreecommitdiffstats
path: root/dialogs
diff options
context:
space:
mode:
authorPinapelz <yukais@pinapelz.com>2026-10-30 13:46:12 -0700
committerPinapelz <yukais@pinapelz.com>2026-10-30 13:46:12 -0700
commitca7cb853bdf289f9e0776fe8ac9d517ca744e204 (patch)
treef50645520f9474ab0b54e499c4f83a38115bfeec /dialogs
parentc26370de18bd892df5c22a81f9b613a07bb67638 (diff)
chore(gui): refactor dialogs into their own modules
Diffstat (limited to 'dialogs')
-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
6 files changed, 802 insertions, 0 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()
send patches to the email below
yukais@pinapelz.com
include the subject [PATCH repo_name]
pinapelz.com
homepage