diff options
Diffstat (limited to 'dialogs')
| -rw-r--r-- | dialogs/__init__.py | 13 | ||||
| -rw-r--r-- | dialogs/album_info_dialog.py | 204 | ||||
| -rw-r--r-- | dialogs/cover_dialog.py | 159 | ||||
| -rw-r--r-- | dialogs/import_dialog.py | 279 | ||||
| -rw-r--r-- | dialogs/reorganize_dialog.py | 97 | ||||
| -rw-r--r-- | dialogs/rip_cd_dialog.py | 50 |
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() |
