diff --git a/src/main.py b/src/main.py index ea7adc1..b77883f 100644 --- a/src/main.py +++ b/src/main.py @@ -15,7 +15,7 @@ from PyQt6.QtWidgets import ( QPushButton, QLabel, QLineEdit, QProgressBar, QFileDialog, QComboBox, QCheckBox, QTabWidget, QListWidget, QListWidgetItem, QMessageBox, QSpinBox, QGroupBox, QHeaderView, - QTableWidget, QTableWidgetItem, QSlider + QTableWidget, QTableWidgetItem, QSlider, QMenu ) from PyQt6.QtCore import Qt, QThread, pyqtSignal, QUrl from PyQt6.QtMultimedia import QMediaPlayer, QAudioOutput @@ -219,8 +219,10 @@ class MainWindow(QMainWindow): self.downloaded_tracks: List[TrackInfo] = [] self.library_ready: List[Dict] = [] self.library_source_files: List[str] = [] + self.library_scanned_sources: List[Dict] = [] self.ipod_devices: List[Dict] = [] self.current_mount_point: Optional[str] = None + self.show_download_tab = False self.player: Optional[QMediaPlayer] = None self.audio_output: Optional[QAudioOutput] = None @@ -248,25 +250,28 @@ class MainWindow(QMainWindow): main_widget = QWidget() main_layout = QVBoxLayout(main_widget) - tab_widget = QTabWidget() - main_layout.addWidget(tab_widget) + self.tab_widget = QTabWidget() + main_layout.addWidget(self.tab_widget) download_tab = QWidget() - tab_widget.addTab(download_tab, "Download") + self.tab_widget.addTab(download_tab, "Download") self._setup_download_tab(download_tab) + self.download_tab_index = 0 library_tab = QWidget() - tab_widget.addTab(library_tab, "Library") + self.tab_widget.addTab(library_tab, "Library") self._setup_library_tab(library_tab) ipod_tab = QWidget() - tab_widget.addTab(ipod_tab, "iPod") + self.tab_widget.addTab(ipod_tab, "iPod") self._setup_ipod_tab(ipod_tab) settings_tab = QWidget() - tab_widget.addTab(settings_tab, "Settings") + self.tab_widget.addTab(settings_tab, "Settings") self._setup_settings_tab(settings_tab) + self.tab_widget.setTabVisible(self.download_tab_index, self.show_download_tab) + self.statusBar().showMessage("Ready") self.setCentralWidget(main_widget) @@ -384,30 +389,23 @@ class MainWindow(QMainWindow): layout.addWidget(playback_group) - # Refresh button - refresh_row = QHBoxLayout() - refresh_btn = QPushButton("\u21BB Refresh Library") - refresh_btn.clicked.connect(self._on_refresh_library_clicked) - refresh_row.addStretch() - refresh_row.addWidget(refresh_btn) - layout.addLayout(refresh_row) + self._setup_library_toolbar(layout) - self.ready_label = QLabel("Ready to Transfer:") - layout.addWidget(self.ready_label) - - self.library_ready_table = QTableWidget() - self.library_ready_table.setColumnCount(7) - self.library_ready_table.setHorizontalHeaderLabels( + self.library_table = QTableWidget() + self.library_table.setColumnCount(7) + self.library_table.setHorizontalHeaderLabels( ["#", "Artist", "Title", "Album", "Duration", "Genre", "Size"] ) - self.library_ready_table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) - self.library_ready_table.setSelectionMode(QTableWidget.SelectionMode.ExtendedSelection) - self.library_ready_table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) - self.library_ready_table.setAlternatingRowColors(True) - self.library_ready_table.setSortingEnabled(True) - self.library_ready_table.cellDoubleClicked.connect(self._on_table_double_clicked) + self.library_table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) + self.library_table.setSelectionMode(QTableWidget.SelectionMode.ExtendedSelection) + self.library_table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) + self.library_table.setAlternatingRowColors(True) + self.library_table.setSortingEnabled(True) + self.library_table.cellDoubleClicked.connect(self._on_table_double_clicked) + self.library_table.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self.library_table.customContextMenuRequested.connect(self._on_library_table_context_menu) - header = self.library_ready_table.horizontalHeader() + header = self.library_table.horizontalHeader() header.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch) header.setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch) header.setSectionResizeMode(3, QHeaderView.ResizeMode.Stretch) @@ -416,32 +414,7 @@ class MainWindow(QMainWindow): header.setSectionResizeMode(5, QHeaderView.ResizeMode.ResizeToContents) header.setSectionResizeMode(6, QHeaderView.ResizeMode.ResizeToContents) - layout.addWidget(self.library_ready_table, stretch=2) - - self.source_label = QLabel("Source Files — Need Conversion:") - layout.addWidget(self.source_label) - - self.library_source_list = QListWidget() - self.library_source_list.setSelectionMode(QListWidget.SelectionMode.ExtendedSelection) - layout.addWidget(self.library_source_list, stretch=1) - - btn_row = QHBoxLayout() - self.library_add_btn = QPushButton("Add Files...") - self.library_add_btn.clicked.connect(self._on_library_add_files) - self.library_remove_btn = QPushButton("Remove Selected") - self.library_remove_btn.clicked.connect(self._on_library_remove_selected) - btn_row.addWidget(self.library_add_btn) - btn_row.addWidget(self.library_remove_btn) - btn_row.addStretch() - layout.addLayout(btn_row) - - self.library_convert_btn = QPushButton("Convert Selected") - self.library_convert_btn.clicked.connect(self._on_library_convert_clicked) - layout.addWidget(self.library_convert_btn) - - self.library_transfer_btn = QPushButton("Transfer Selected to iPod \u25B8") - self.library_transfer_btn.clicked.connect(self._on_library_transfer_clicked) - layout.addWidget(self.library_transfer_btn) + layout.addWidget(self.library_table, stretch=1) self.library_progress = QProgressBar() self.library_progress.setRange(0, 100) @@ -450,6 +423,44 @@ class MainWindow(QMainWindow): self.library_status = QLabel("Ready") layout.addWidget(self.library_status) + def _setup_library_toolbar(self, layout): + toolbar = QHBoxLayout() + toolbar.setSpacing(4) + + self.library_add_btn = QPushButton("\u2795 Add Files...") + self.library_add_btn.clicked.connect(self._on_library_add_files) + toolbar.addWidget(self.library_add_btn) + + self.library_convert_btn = QPushButton("\uD83D\uDD04 Convert") + self.library_convert_btn.setEnabled(False) + self.library_convert_btn.clicked.connect(self._on_library_convert_clicked) + toolbar.addWidget(self.library_convert_btn) + + self.library_transfer_btn = QPushButton("\uD83D\uDCE4 Transfer") + self.library_transfer_btn.setEnabled(False) + self.library_transfer_btn.clicked.connect(self._on_library_transfer_clicked) + toolbar.addWidget(self.library_transfer_btn) + + self.library_remove_btn = QPushButton("\uD83D\uDDD1\uFE0F Remove") + self.library_remove_btn.setEnabled(False) + self.library_remove_btn.clicked.connect(self._on_library_remove_selected) + toolbar.addWidget(self.library_remove_btn) + + refresh_btn = QPushButton("\uD83D\uDD04") + refresh_btn.setToolTip("Refresh Library") + refresh_btn.clicked.connect(self._on_refresh_library_clicked) + toolbar.addWidget(refresh_btn) + + toolbar.addStretch() + + self.library_search_input = QLineEdit() + self.library_search_input.setPlaceholderText("\uD83D\uDD0D Search Library...") + self.library_search_input.setFixedWidth(220) + self.library_search_input.textChanged.connect(self._on_search_text_changed) + toolbar.addWidget(self.library_search_input) + + layout.addLayout(toolbar) + def _setup_ipod_tab(self, tab): layout = QVBoxLayout(tab) @@ -565,6 +576,11 @@ class MainWindow(QMainWindow): self.auto_detect_check.setChecked(True) advanced_layout.addWidget(self.auto_detect_check) + self.show_download_check = QCheckBox("Show YouTube Download Tab") + self.show_download_check.setChecked(False) + self.show_download_check.toggled.connect(self._on_show_download_toggled) + advanced_layout.addWidget(self.show_download_check) + layout.addWidget(advanced_group) layout.addStretch() @@ -577,6 +593,10 @@ class MainWindow(QMainWindow): about_label.setAlignment(Qt.AlignmentFlag.AlignCenter) layout.addWidget(about_label) + def _on_show_download_toggled(self, checked: bool): + self.show_download_tab = checked + self.tab_widget.setTabVisible(self.download_tab_index, checked) + def _setup_player(self): """Initialize QMediaPlayer and QAudioOutput for playback.""" self.player = QMediaPlayer() @@ -590,12 +610,12 @@ class MainWindow(QMainWindow): def _play_track_from_index(self, index: int): """Start playing a track from the library table by row index.""" - if index < 0 or index >= self.library_ready_table.rowCount(): + if index < 0 or index >= self.library_table.rowCount(): return - data_item = self.library_ready_table.item(index, 0) + data_item = self.library_table.item(index, 0) if data_item is None: return - track_data = data_item.data(Qt.ItemDataRole.UserRole) + is_ready, track_data = data_item.data(Qt.ItemDataRole.UserRole) if not track_data or not os.path.exists(track_data.get("path", "")): return @@ -611,8 +631,8 @@ class MainWindow(QMainWindow): ) # Highlight the current row - self.library_ready_table.clearSelection() - self.library_ready_table.selectRow(index) + self.library_table.clearSelection() + self.library_table.selectRow(index) def _on_play_pause_clicked(self): if self.player is None: @@ -624,7 +644,7 @@ class MainWindow(QMainWindow): else: if self.player.playbackState() == QMediaPlayer.PlaybackState.StoppedState: # Nothing playing yet — play first selected row or row 0 - selected_rows = {item.row() for item in self.library_ready_table.selectedItems()} + selected_rows = {item.row() for item in self.library_table.selectedItems()} start = min(selected_rows) if selected_rows else 0 self._play_track_from_index(start) else: @@ -644,7 +664,7 @@ class MainWindow(QMainWindow): def _on_next_clicked(self): if self.player is None: return - idx = min(self.library_ready_table.rowCount() - 1, self.current_playback_index + 1) + idx = min(self.library_table.rowCount() - 1, self.current_playback_index + 1) self._play_track_from_index(idx) def _on_slider_pressed(self): @@ -705,8 +725,8 @@ class MainWindow(QMainWindow): output_dir = self.output_dir_input.text().strip() if not output_dir or not os.path.isdir(output_dir): self.library_ready.clear() + self.library_scanned_sources = [] self._refresh_library_ui() - self._refresh_source_ui([]) return ready = [] @@ -778,68 +798,33 @@ class MainWindow(QMainWindow): ready = deduped_ready self.library_ready = ready + self.library_scanned_sources = sources self._refresh_library_ui() - self._refresh_source_ui(sources) def _refresh_library_ui(self): - self.library_ready_table.setRowCount(0) - self.library_ready_table.setSortingEnabled(False) + self.library_table.setSortingEnabled(False) + self.library_table.setRowCount(0) - for track in sorted( - self.library_ready, - key=lambda t: (t.get("track_num") or 0, t["artist"].lower(), t["title"].lower()) - ): - row = self.library_ready_table.rowCount() - self.library_ready_table.insertRow(row) - - track_num_str = str(track["track_num"]) if track.get("track_num") else "—" - - cells = [ - QTableWidgetItem(track_num_str), - QTableWidgetItem(track["artist"]), - QTableWidgetItem(track["title"]), - QTableWidgetItem(track.get("album", "") or ""), - QTableWidgetItem(self._format_duration(track["duration_s"])), - QTableWidgetItem(track.get("genre", "") or ""), - QTableWidgetItem(self._format_size(track["size"])), - ] - - cells[0].setData(Qt.ItemDataRole.UserRole, track) - for col, item in enumerate(cells): - self.library_ready_table.setItem(row, col, item) - - self.library_ready_table.setSortingEnabled(True) - - count = len(self.library_ready) - self.ready_label.setVisible(count > 0) - self.library_ready_table.setVisible(count > 0) - if count: - self.library_status.setText(f"{count} track(s) ready to transfer") - self.library_transfer_btn.setEnabled(True) - else: - self.library_status.setText("No tracks ready — convert source files or download") - self.library_transfer_btn.setEnabled(False) - - def _refresh_source_ui(self, scanned_sources): - """Build the Source Files list by combining scanned files + manual additions. - - Deduplicates by path. All items get UserRole data for _get_selected_source_files(). - """ - self.library_source_list.clear() - seen_paths = set() - all_sources = [] + all_tracks = [] - for src in scanned_sources: - path = src.get("path", "") + # Add ready tracks + for t in self.library_ready: + path = t.get("path", "") if path and path not in seen_paths and os.path.exists(path): seen_paths.add(path) - all_sources.append(src) + all_tracks.append({**t, "_status": "ready"}) + # Add scanned source files + for s in self.library_scanned_sources: + path = s.get("path", "") + if path and path not in seen_paths and os.path.exists(path): + seen_paths.add(path) + all_tracks.append({**s, "duration_s": 0, "genre": "", "_status": "source"}) + + # Add manually added source files for fpath in self.library_source_files: - if fpath in seen_paths: - continue - if not os.path.exists(fpath): + if fpath in seen_paths or not os.path.exists(fpath): continue fname = os.path.basename(fpath) size = os.path.getsize(fpath) @@ -851,65 +836,115 @@ class MainWindow(QMainWindow): except Exception: title = fname artist = "Unknown" - - src = { - "path": fpath, - "title": title, - "artist": artist, - "size": size, - } - all_sources.append(src) seen_paths.add(fpath) + all_tracks.append({ + "path": fpath, "title": title, "artist": artist, + "size": size, "duration_s": 0, "genre": "", "album": "", + "_status": "source", + }) - for src in sorted(all_sources, key=lambda s: (s["artist"], s["title"])): - display = f"{src['artist']} — {src['title']} {self._format_size(src['size'])}" - item = QListWidgetItem(display) - item.setData(Qt.ItemDataRole.UserRole, src) - self.library_source_list.addItem(item) + all_tracks.sort(key=lambda t: (t["artist"].lower(), t["title"].lower())) - count = len(all_sources) - self.source_label.setVisible(count > 0) - self.library_source_list.setVisible(count > 0) - self.library_convert_btn.setEnabled(count > 0) + for idx, track in enumerate(all_tracks): + row = self.library_table.rowCount() + self.library_table.insertRow(row) + + is_ready = track["_status"] == "ready" + + track_num_str = str(track.get("track_num") or "") if is_ready else "" + duration_str = self._format_duration(track.get("duration_s", 0)) if is_ready else "\u2014" + + cells = [ + QTableWidgetItem(track_num_str), + QTableWidgetItem(track["artist"]), + QTableWidgetItem(track["title"]), + QTableWidgetItem(track.get("album", "") or ""), + QTableWidgetItem(duration_str), + QTableWidgetItem(track.get("genre", "") or ""), + QTableWidgetItem(self._format_size(track["size"])), + ] + + # Dim source tracks (gray text) like iTunes + if not is_ready: + gray = self.palette().color(self.palette().ColorRole.PlaceholderText) + for item in cells: + item.setForeground(gray) + + # Store full track data in column 0 + data_key = track.copy() + data_key.pop("_status", None) + cells[0].setData(Qt.ItemDataRole.UserRole, (is_ready, data_key)) + + for col, item in enumerate(cells): + self.library_table.setItem(row, col, item) + + self.library_table.setSortingEnabled(True) + + ready_count = sum(1 for t in all_tracks if t["_status"] == "ready") + source_count = len(all_tracks) - ready_count + + has_ready = ready_count > 0 + has_source = source_count > 0 + + self.library_transfer_btn.setEnabled(has_ready) + self.library_convert_btn.setEnabled(has_source) + self.library_remove_btn.setEnabled(len(all_tracks) > 0) + + parts = [] + if ready_count: + parts.append(f"{ready_count} ready") + if source_count: + parts.append(f"{source_count} need conversion") + status = f"{len(all_tracks)} track(s) \u2014 {', '.join(parts)}" if parts else "No tracks in library" + self.library_status.setText(status) def _get_selected_ready_tracks(self) -> List[Tuple[TrackInfo, str]]: selected_rows = set() - for item in self.library_ready_table.selectedItems(): + for item in self.library_table.selectedItems(): selected_rows.add(item.row()) if not selected_rows: - selected_rows = set(range(self.library_ready_table.rowCount())) + selected_rows = set(range(self.library_table.rowCount())) tracks = [] for row in sorted(selected_rows): - data_item = self.library_ready_table.item(row, 0) + data_item = self.library_table.item(row, 0) if data_item is None: continue - data = data_item.data(Qt.ItemDataRole.UserRole) - if data: - track_info = TrackInfo( - video_id=f"local_{hash(data['path'])}", - title=data['title'], - artist=data['artist'], - album=data.get("album", ""), - thumbnail_url="", - duration=data.get('duration_s', 0), - track_number=data.get('track_num'), - genre=data.get('genre'), - download_path=data['path'], - ) - tracks.append((track_info, data['path'])) + is_ready, data = data_item.data(Qt.ItemDataRole.UserRole) + if not is_ready: + continue + track_info = TrackInfo( + video_id=f"local_{hash(data['path'])}", + title=data['title'], + artist=data['artist'], + album=data.get("album", ""), + thumbnail_url="", + duration=data.get('duration_s', 0), + track_number=data.get('track_num'), + genre=data.get('genre'), + download_path=data['path'], + ) + tracks.append((track_info, data['path'])) return tracks def _get_selected_source_files(self) -> List[str]: - selected = self.library_source_list.selectedItems() - if not selected: - selected = [self.library_source_list.item(i) for i in range(self.library_source_list.count())] + selected_rows = set() + for item in self.library_table.selectedItems(): + selected_rows.add(item.row()) + + if not selected_rows: + selected_rows = set(range(self.library_table.rowCount())) paths = [] - for item in selected: - data = item.data(Qt.ItemDataRole.UserRole) - if data and os.path.exists(data.get("path", "")): + for row in sorted(selected_rows): + data_item = self.library_table.item(row, 0) + if data_item is None: + continue + is_ready, data = data_item.data(Qt.ItemDataRole.UserRole) + if is_ready: + continue + if os.path.exists(data.get("path", "")): paths.append(data["path"]) return paths @@ -938,26 +973,21 @@ class MainWindow(QMainWindow): new_files.append(f) if new_files: - self._refresh_source_ui([]) + self._scan_library() def _on_library_remove_selected(self): """Delete selected files from disk and clean up empty directories.""" files_to_delete = [] selected_rows = set() - for item in self.library_ready_table.selectedItems(): + for item in self.library_table.selectedItems(): selected_rows.add(item.row()) for row in selected_rows: - data_item = self.library_ready_table.item(row, 0) + data_item = self.library_table.item(row, 0) if data_item is None: continue - data = data_item.data(Qt.ItemDataRole.UserRole) - if data and os.path.exists(data.get("path", "")): - files_to_delete.append(data) - - for item in self.library_source_list.selectedItems(): - data = item.data(Qt.ItemDataRole.UserRole) + is_ready, data = data_item.data(Qt.ItemDataRole.UserRole) if data and os.path.exists(data.get("path", "")): files_to_delete.append(data) @@ -1002,6 +1032,57 @@ class MainWindow(QMainWindow): self._scan_library() QMessageBox.information(self, "Done", f"Deleted {deleted} file(s)") + def _on_search_text_changed(self, text: str): + for row in range(self.library_table.rowCount()): + item = self.library_table.item(row, 3) + match = not text or text.lower() in item.text().lower() if item else True + self.library_table.setRowHidden(row, not match) + + def _on_library_table_context_menu(self, pos): + item = self.library_table.itemAt(pos) + if item is None: + return + row = item.row() + data_item = self.library_table.item(row, 0) + if data_item is None: + return + is_ready, track_data = data_item.data(Qt.ItemDataRole.UserRole) + + menu = QMenu(self) + + play_action = menu.addAction("\u25B6 Play") + play_action.triggered.connect(lambda: self._play_track_from_index(row)) + + if is_ready: + transfer_action = menu.addAction("\uD83D\uDCE4 Transfer to iPod") + transfer_action.triggered.connect(self._on_library_transfer_clicked) + else: + convert_action = menu.addAction("\uD83D\uDD04 Convert to iPod Format") + convert_action.triggered.connect(self._on_library_convert_clicked) + + menu.addSeparator() + + show_action = menu.addAction("\uD83D\uDCC2 Show in File Manager") + show_action.triggered.connect(lambda: self._on_show_in_file_manager(track_data)) + + remove_action = menu.addAction("\uD83D\uDDD1\uFE0F Remove from Library") + remove_action.triggered.connect(self._on_library_remove_selected) + + menu.exec(self.library_table.viewport().mapToGlobal(pos)) + + def _on_show_in_file_manager(self, track_data): + path = track_data.get("path", "") + if not path: + return + import subprocess + platform = sys.platform + if platform == "win32": + subprocess.Popen(["explorer", "/select,", os.path.normpath(path)]) + elif platform == "darwin": + subprocess.Popen(["open", "-R", path]) + else: + subprocess.Popen(["xdg-open", os.path.dirname(path)]) + def _on_download_clicked(self): url = self.url_input.text().strip() if not url: