diff --git a/src/app.py b/src/app.py index 7859c0b..d8afb2e 100644 --- a/src/app.py +++ b/src/app.py @@ -48,6 +48,8 @@ class MainWindow(QMainWindow): self.playlist_manager = PlaylistManager() self._sidebar_visible = True + self._pending_playlist_id: str = "" + self._pending_playlist_tracks: list = [] self._saved_volume: int = 80 self._saved_track_path: str = "" @@ -141,7 +143,9 @@ class MainWindow(QMainWindow): self.sidebar.tracks_dropped_on_playlist.connect(self._on_tracks_dropped_on_playlist) self.sidebar.tracks_dropped_for_nav_transfer.connect(self._on_tracks_dropped_for_nav_transfer) + self.sidebar.playlist_dropped_for_nav_transfer.connect(self._on_playlist_dropped_for_nav_transfer) self.ipod_tab.tracks_dropped_for_transfer.connect(self._on_tracks_dropped_for_transfer) + self.library_tab.transfer_finished.connect(self._on_transfer_finished_for_playlist) def _on_sidebar_item_selected(self, kind: str, value: str): if kind == "tab": @@ -197,6 +201,55 @@ class MainWindow(QMainWindow): self.stack.setCurrentIndex(0) self.library_tab.transfer_tracks_by_paths(paths) + def _on_playlist_dropped_for_nav_transfer(self, playlist_id: str): + playlist = self.playlist_manager.get_by_id(playlist_id) + if not playlist or not playlist.track_paths: + return + + self.stack.setCurrentIndex(0) + self._pending_playlist_id = playlist_id + self._pending_playlist_tracks = list(playlist.track_paths) + self.library_tab.transfer_tracks_by_paths(playlist.track_paths) + + def _on_transfer_finished_for_playlist(self): + playlist_id = self._pending_playlist_id + track_paths = self._pending_playlist_tracks + self._pending_playlist_id = "" + self._pending_playlist_tracks = [] + + if not playlist_id or not track_paths: + return + + mount_point = getattr(self.ipod_tab, "current_mount_point", None) + if not mount_point: + self.statusBar().showMessage("Cannot create playlist: iPod not mounted", 5000) + return + + playlist = self.playlist_manager.get_by_id(playlist_id) + if not playlist: + return + + self.statusBar().showMessage(f"Creating playlist '{playlist.name}' on iPod...") + + from worker import WorkerThread + self._pl_worker = WorkerThread( + task_type="create_playlist", + mount_point=mount_point, + playlist_name=playlist.name, + track_paths=track_paths, + ) + self._pl_worker.finished_signal.connect(self._on_playlist_creation_finished) + self._pl_worker.start() + + def _on_playlist_creation_finished(self, success, message, result): + self.statusBar().showMessage(message, 8000) + try: + self._pl_worker.quit() + self._pl_worker.wait() + except Exception: + pass + self._pl_worker = None + def _load_playlists_into_sidebar(self): for pl in self.playlist_manager.get_all(): self.sidebar.add_playlist_item(pl.id, pl.name) diff --git a/src/ipod_nano7_db.py b/src/ipod_nano7_db.py index d810873..ad18a43 100644 --- a/src/ipod_nano7_db.py +++ b/src/ipod_nano7_db.py @@ -32,7 +32,7 @@ import sqlite3 import struct import subprocess import time -from typing import Any, Optional +from typing import Any, Callable, Optional from mutagen import File as MutagenFile from mutagen.easyid3 import EasyID3 @@ -627,6 +627,114 @@ class Nano7Database: deleted += 1 return deleted + # ── playlist creation ──────────────────────────────────────────── + + def create_playlist(self, name: str, track_paths: list[str]) -> bool: + """Create a user playlist on the iPod. + + Two-pass matching: content hash first, then title+artist+album + fallback. Only tracks already present on the iPod will be + included in the playlist. + + Args: + name: Playlist name. + track_paths: Local file paths of the tracks to include. + + Returns: + True if the playlist was created, False otherwise. + """ + scanned = self._scan_ipod_files() + if not scanned: + logger.warning("No tracks on iPod — cannot create playlist") + return False + + playlist_created = False + self._merge_play_stats(scanned) + + def _build_pl_cb(scanned, tracks): + """Build playlist using real db_track_id from TrackInfo.""" + nonlocal playlist_created + + try: + IopPlaylistInfo = _import_iop( + "iTunesDB_Writer.mhyp_writer" + ).PlaylistInfo + except Exception as e: + logger.exception("Failed to import PlaylistInfo: %s", e) + return None + + # Pass 1: content_hash → db_track_id + hash_to_db_id: dict[str, int] = {} + for i, s in enumerate(scanned): + ch = s.get("content_hash", "") + if ch: + hash_to_db_id[ch] = tracks[i].db_track_id + + found_ids: list[int] = [] + unmatched: list[str] = [] + + for path in track_paths: + if not os.path.exists(path): + continue + ch = content_hash(path) + db_id = hash_to_db_id.get(ch) + if db_id is not None: + found_ids.append(db_id) + else: + unmatched.append(path) + + hash_matched_count = len(found_ids) + + # Pass 2: metadata fallback + if unmatched: + scanned_meta = [] + for i, s in enumerate(scanned): + scanned_meta.append(( + s.get("title", "").lower(), + s.get("artist", "").lower(), + (s.get("album") or "").lower(), + tracks[i].db_track_id, + )) + + found_set = set(found_ids) + for path in unmatched: + tags = _read_audio_tags(path) + title = (tags.get("title") or "").lower() + artist = (tags.get("artist") or "").lower() + album = (tags.get("album") or "").lower() + if not title or not artist: + continue + for s_title, s_artist, s_album, db_id in scanned_meta: + if (db_id not in found_set + and s_title == title + and s_artist == artist + and s_album == album): + found_ids.append(db_id) + found_set.add(db_id) + break + + if not found_ids: + logger.warning( + "No tracks from playlist '%s' found on iPod", + name, + ) + return None + + matched_by_hash = hash_matched_count + matched_by_meta = len(found_ids) - hash_matched_count + logger.info( + "Playlist '%s': %d track(s) matched (%d by hash, %d by metadata)", + name, len(found_ids), matched_by_hash, matched_by_meta, + ) + playlist_created = True + return [IopPlaylistInfo(name=name, track_ids=found_ids)] + + db_ok = self._write_databases_from_tracks( + scanned, build_extra_playlists=_build_pl_cb, + ) + self._cleanup_play_counts_file() + return db_ok and playlist_created + # ── sync: regenerate everything via iOpenPod ────────────────────── def sync_itunescdb(self) -> None: @@ -694,7 +802,9 @@ class Nano7Database: return self._merge_play_stats(scanned) - self._write_databases_from_tracks(scanned) + if not self._write_databases_from_tracks(scanned): + logger.error("iTunesDB sync failed — play counts not cleaned up") + return self._cleanup_play_counts_file() def find_track_by_content_hash(self, content_hash: str) -> dict | None: @@ -901,7 +1011,9 @@ class Nano7Database: self, scanned: list[dict], artwork_overrides: dict[str, object] | None = None, - ) -> None: + extra_playlists: list | None = None, + build_extra_playlists: Callable | None = None, + ) -> bool: """Build artwork, convert to TrackInfo, write iTunesDB + SQLite. Args: @@ -911,6 +1023,15 @@ class Nano7Database: artwork should come from a local file instead of the iPod file. Use the ``_REMOVE_ARTWORK`` sentinel to explicitly remove artwork for a track. Omit a key to keep existing iPod artwork. + extra_playlists: Pre-built list of PlaylistInfo (ignored when + build_extra_playlists is provided). + build_extra_playlists: Optional callable(scanned, tracks) → list + of PlaylistInfo. Called after TrackInfo conversion and + db_track_id generation, so the callback can use real + db_track_id values from TrackInfo objects. + + Returns: + True if databases were written successfully. """ artwork_overrides = artwork_overrides or {} # 1. Prepare artwork for all tracks @@ -970,6 +1091,20 @@ class Nano7Database: tracks = _scanned_tracks_to_iop(self.mountpoint, scanned, artwork_map) logger.info("Built %d iOpenPod TrackInfo objects", len(tracks)) + # 2b. Pre-generate db_track_id for all tracks so callbacks can use them + generate_db_track_id = _import_iop("iTunesDB_Writer.mhit_writer").generate_db_track_id + for track in tracks: + if track.db_track_id == 0: + track.db_track_id = generate_db_track_id() + + # 2c. Build extra playlists via callback if provided + if build_extra_playlists is not None: + result = build_extra_playlists(scanned, tracks) + if result is not None: + extra_playlists = result + else: + extra_playlists = None + # 3. Determine capabilities for this device ipod_device = _import_iop("ipod_device") capabilities = None @@ -998,10 +1133,11 @@ class Nano7Database: backup=True, capabilities=capabilities, master_playlist_name="iPod", + playlists=extra_playlists or None, ) if not ok: logger.error("iTunesDB write failed") - return + return False logger.info("iTunesDB written (%d tracks)", len(tracks)) # 5. Extract db_pid from the binary DB for SQLite cross-reference @@ -1029,13 +1165,18 @@ class Nano7Database: capabilities=capabilities, firewire_id=firewire_id, backup=True, + playlists=extra_playlists or None, ) if sqlite_ok: logger.info("SQLite databases written") else: logger.error("SQLite database write failed") + return False except Exception as e: logger.exception("SQLite write error: %s", e) + return False + + return True def _write_empty_databases(self) -> None: """Write minimal empty databases when no tracks exist.""" diff --git a/src/ui/sidebar_widget.py b/src/ui/sidebar_widget.py index 31b2c85..9ae73b5 100644 --- a/src/ui/sidebar_widget.py +++ b/src/ui/sidebar_widget.py @@ -27,6 +27,14 @@ class PlaylistDropListWidget(QListWidget): self.setAcceptDrops(True) self._hovered_item = None + def mimeData(self, items): + mime = super().mimeData(items) + if items: + playlist_id = items[0].data(Qt.ItemDataRole.UserRole) + if playlist_id: + mime.setData("application/x-neopod-playlist", playlist_id.encode()) + return mime + def dragEnterEvent(self, event: QDragEnterEvent): if event.source() is not self and event.mimeData().hasUrls(): event.acceptProposedAction() @@ -83,6 +91,7 @@ class PlaylistDropListWidget(QListWidget): class NavDropListWidget(QListWidget): """QListWidget that accepts external track drops on the iPod nav item.""" tracks_dropped_for_transfer = pyqtSignal(list) + playlist_dropped_for_transfer = pyqtSignal(str) def __init__(self, parent=None): super().__init__(parent) @@ -95,7 +104,19 @@ class NavDropListWidget(QListWidget): return item return None + def _repolish(self): + self.style().unpolish(self) + self.style().polish(self) + def dragEnterEvent(self, event: QDragEnterEvent): + if event.mimeData().hasFormat("application/x-neopod-playlist"): + self.setProperty("dndActive", True) + self._repolish() + pos = event.position().toPoint() + if self._target_item_at(pos): + event.acceptProposedAction() + return + # Let default handler accept so dragMoveEvent still fires if event.source() is not self and event.mimeData().hasUrls(): pos = event.position().toPoint() if self._target_item_at(pos): @@ -104,6 +125,7 @@ class NavDropListWidget(QListWidget): super().dragEnterEvent(event) def dragMoveEvent(self, event: QDragMoveEvent): + accept = False if event.source() is not self and event.mimeData().hasUrls(): pos = event.position().toPoint() item = self._target_item_at(pos) @@ -115,15 +137,34 @@ class NavDropListWidget(QListWidget): item.setBackground(bg) if item: event.acceptProposedAction() - return - super().dragMoveEvent(event) + accept = True + if not accept and event.mimeData().hasFormat("application/x-neopod-playlist"): + pos = event.position().toPoint() + item = self._target_item_at(pos) + if item != self._hovered_item: + self._clear_hover() + if item: + self._hovered_item = item + bg = self.palette().color(self.palette().ColorRole.Highlight) + item.setBackground(bg) + if item: + event.acceptProposedAction() + accept = True + if not accept: + event.ignore() def dragLeaveEvent(self, event: QDragLeaveEvent): self._clear_hover() + if self.property("dndActive"): + self.setProperty("dndActive", False) + self._repolish() super().dragLeaveEvent(event) def dropEvent(self, event: QDropEvent): self._clear_hover() + if self.property("dndActive"): + self.setProperty("dndActive", False) + self._repolish() if event.source() is not self and event.mimeData().hasUrls(): pos = event.position().toPoint() if self._target_item_at(pos): @@ -133,6 +174,14 @@ class NavDropListWidget(QListWidget): self.tracks_dropped_for_transfer.emit(paths) event.acceptProposedAction() return + if event.mimeData().hasFormat("application/x-neopod-playlist"): + pos = event.position().toPoint() + if self._target_item_at(pos): + playlist_id = bytes(event.mimeData().data("application/x-neopod-playlist")).decode() + if playlist_id: + self.playlist_dropped_for_transfer.emit(playlist_id) + event.acceptProposedAction() + return super().dropEvent(event) def _clear_hover(self): @@ -146,6 +195,7 @@ class SidebarWidget(QWidget): playlist_selected = pyqtSignal(str) tracks_dropped_on_playlist = pyqtSignal(str, list) tracks_dropped_for_nav_transfer = pyqtSignal(list) + playlist_dropped_for_nav_transfer = pyqtSignal(str) def __init__(self, parent=None): super().__init__(parent) @@ -205,6 +255,9 @@ class SidebarWidget(QWidget): QListWidget::item:hover:!selected {{ background: {hl_light_name}; }} + #navList[dndActive="true"]::item:hover:!selected {{ + background: transparent; + }} """) def _build_ui(self): @@ -292,6 +345,7 @@ class SidebarWidget(QWidget): layout.addWidget(line2) self._nav_list = NavDropListWidget() + self._nav_list.setObjectName("navList") items = [ ("🎸 iPod", "ipod"), ("⚙️ Settings", "settings"), @@ -303,6 +357,7 @@ class SidebarWidget(QWidget): self._nav_list.addItem(item) self._nav_list.itemClicked.connect(self._on_nav_item_clicked) self._nav_list.tracks_dropped_for_transfer.connect(self._on_tracks_dropped_for_nav_transfer) + self._nav_list.playlist_dropped_for_transfer.connect(self._on_playlist_dropped_for_transfer) layout.addWidget(self._nav_list) self._playlist_items: dict = {} @@ -396,6 +451,9 @@ class SidebarWidget(QWidget): def _on_tracks_dropped_for_nav_transfer(self, paths: list): self.tracks_dropped_for_nav_transfer.emit(paths) + def _on_playlist_dropped_for_transfer(self, playlist_id: str): + self.playlist_dropped_for_nav_transfer.emit(playlist_id) + def _on_playlists_reordered(self): order = self.get_playlist_order_ids() self.item_selected.emit("playlist_reorder", "|".join(order)) diff --git a/src/worker.py b/src/worker.py index d89ed99..80a6de2 100644 --- a/src/worker.py +++ b/src/worker.py @@ -44,6 +44,7 @@ class WorkerThread(QThread): "delete_tracks": self._run_delete_tracks, "remove_duplicates": self._run_remove_duplicates, "sync_metadata": self._run_sync_metadata, + "create_playlist": self._run_create_playlist, } handler = handlers.get(self.task_type) if handler: @@ -362,6 +363,26 @@ class WorkerThread(QThread): self.progress_signal.emit(100, msg) self.finished_signal.emit(synced > 0, msg, {"synced": synced, "errors": errors}) + def _run_create_playlist(self): + mount_point = self.kwargs.get("mount_point") + name = self.kwargs.get("playlist_name", "") + track_paths = self.kwargs.get("track_paths", []) + + if not mount_point or not name: + self.finished_signal.emit(False, "Missing parameters for playlist creation", None) + return + + from ipod_nano7_db import Nano7Database + db = Nano7Database(mount_point) + + self.progress_signal.emit(0, f"Creating playlist '{name}' on iPod...") + success = db.create_playlist(name, track_paths) + + if success: + self.finished_signal.emit(True, f"Playlist '{name}' created on iPod", None) + else: + self.finished_signal.emit(False, f"Failed to create playlist '{name}'", None) + def stop(self): self.is_running = False self.wait()