aboutsummaryrefslogtreecommitdiffstats
path: root/dialogs/import_dialog.py
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/import_dialog.py
parentc26370de18bd892df5c22a81f9b613a07bb67638 (diff)
chore(gui): refactor dialogs into their own modules
Diffstat (limited to 'dialogs/import_dialog.py')
-rw-r--r--dialogs/import_dialog.py279
1 files changed, 279 insertions, 0 deletions
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()
send patches to the email below
yukais@pinapelz.com
include the subject [PATCH repo_name]
pinapelz.com
homepage