diff --git a/README.md b/README.md index ba8cb49..bbe7212 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ - + -An open-source Python tool that enables direct transfer of YouTube music/playlists to iPod Nano, bypassing iTunes. This application combines YouTube audio extraction, format conversion, metadata management, and direct device transfer while adhering to iPod Nano's technical specifications. +Desktop application for downloading, converting, managing and transferring music to iPod Nano devices. Combines YouTube audio extraction, format conversion, metadata management, local library with playback, and direct device transfer while adhering to iPod Nano's technical specifications. ## Features diff --git a/run.py b/run.py index a4fdec5..7982571 100755 --- a/run.py +++ b/run.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ -Launcher script for YouTube Music to iPod Nano Transfer Tool +Launcher script for neo-pod-desktop """ import os @@ -9,7 +9,7 @@ import argparse def main(): """Main entry point""" - parser = argparse.ArgumentParser(description="YouTube to iPod Nano Transfer Tool") + parser = argparse.ArgumentParser(description="neo-pod-desktop") parser.add_argument("--cli", action="store_true", help="Run in command-line mode") args = parser.parse_args() diff --git a/setup.py b/setup.py index 9c8acf6..18fed53 100644 --- a/setup.py +++ b/setup.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ -Setup script for YouTube Music to iPod Nano Transfer Tool +Setup script for neo-pod-desktop """ from setuptools import setup, find_packages @@ -15,19 +15,19 @@ with open('README.md', encoding='utf-8') as f: long_description = f.read() setup( - name="youtube-to-ipod-nano", + name="neo-pod-desktop", version="1.0.0", - description="Transfer YouTube music/playlists directly to iPod Nano", + description="Desktop application for downloading, converting, managing and transferring music to iPod Nano devices", long_description=long_description, long_description_content_type="text/markdown", - author="Your Name", - author_email="your.email@example.com", - url="https://github.com/yourusername/youtube-to-ipod-nano", + author="neo-pod-desktop", + author_email="", + url="https://github.com/matcohen/neo-pod-desktop", packages=find_packages(), install_requires=requirements, entry_points={ 'console_scripts': [ - 'youtube-to-ipod=src.cli:main', + 'neo-pod-desktop=src.cli:main', ], }, classifiers=[ diff --git a/src/__init__.py b/src/__init__.py index 33c699d..e0e1d6c 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -1,6 +1,6 @@ """ -YouTube Music to iPod Nano Transfer Tool -An open-source Python tool that enables direct transfer of YouTube music/playlists to iPod Nano +neo-pod-desktop +Desktop application for downloading, converting, managing and transferring music to iPod Nano devices """ __version__ = "1.0.0" diff --git a/src/cli.py b/src/cli.py index f4b1bac..800d851 100644 --- a/src/cli.py +++ b/src/cli.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """ -Command Line Interface for iPod Nano Transfer Tool -Provides a CLI for the YouTube Music to iPod Nano transfer tool +Command Line Interface for neo-pod-desktop +Provides a CLI for downloading, converting, managing and transferring music to iPod Nano devices """ import os @@ -25,7 +25,7 @@ logger = logging.getLogger(__name__) class YouTubeToIPodCLI: - """Command Line Interface for YouTube to iPod Nano Transfer Tool""" + """Command Line Interface for neo-pod-desktop""" def __init__(self): """Initialize the CLI""" @@ -43,7 +43,7 @@ class YouTubeToIPodCLI: def parse_arguments(self): """Parse command line arguments""" parser = argparse.ArgumentParser( - description="YouTube to iPod Nano Transfer Tool", + description="neo-pod-desktop", formatter_class=argparse.ArgumentDefaultsHelpFormatter ) diff --git a/src/ipod_nano7_db.py b/src/ipod_nano7_db.py index de64609..2be437a 100644 --- a/src/ipod_nano7_db.py +++ b/src/ipod_nano7_db.py @@ -290,16 +290,16 @@ class Nano7Database: artist = audio_info.get("artist", "Unknown Artist") album = audio_info.get("album", "Unknown Album") - # Check for duplicate: same title + artist + album + # Check for duplicate: same title + artist + album (case-insensitive) conn = sqlite3.connect(self.sqlite_path) cursor = conn.cursor() cursor.execute( - "SELECT pid, title FROM item WHERE title = ? AND artist = ?", - (title, artist) + "SELECT pid, title FROM item WHERE LOWER(title) = LOWER(?) AND LOWER(artist) = LOWER(?) AND LOWER(album) = LOWER(?)", + (title, artist, album) ) existing = cursor.fetchone() if existing: - logger.info(f"Track already exists: '{existing[1]}' by {artist}, skipping") + logger.info(f"Track already exists: '{existing[1]}' by {artist} ({album}), skipping") conn.close() return None @@ -704,6 +704,101 @@ class Nano7Database: logger.error(f"Failed to delete track pid={pid}: {e}") return False + def remove_duplicates(self) -> List[Dict[str, Any]]: + """Find and remove duplicate tracks from the iPod database. + + Duplicates are identified by LOWER(title) + LOWER(artist) + LOWER(album). + Keeps the entry with the lowest physical_order (original). + + Returns list of removed track info dicts. + """ + conn = sqlite3.connect(self.sqlite_path) + cursor = conn.cursor() + + # Find duplicate groups: same title + artist + album, more than one entry + cursor.execute(""" + SELECT LOWER(title) AS lt, LOWER(artist) AS la, LOWER(album) AS lb, + COUNT(*) AS cnt, MIN(physical_order) AS keep_order + FROM item + GROUP BY LOWER(title), LOWER(artist), LOWER(album) + HAVING cnt > 1 + """) + dup_groups = cursor.fetchall() + + if not dup_groups: + conn.close() + logger.info("No duplicate tracks found") + return [] + + # Collect PIDs to remove + pids_to_remove = [] + files_to_remove = [] + removed_info = [] + + for lt, la, lb, cnt, keep_order in dup_groups: + # Get the track to keep (lowest physical_order) + cursor.execute( + "SELECT pid, title, artist, album FROM item WHERE LOWER(title)=? AND LOWER(artist)=? AND LOWER(album)=? AND physical_order=?", + (lt, la, lb, keep_order) + ) + keep_row = cursor.fetchone() + keep_pid = keep_row[0] if keep_row else None + + # Get all duplicates for this group + cursor.execute( + "SELECT pid, title, artist, album, physical_order FROM item WHERE LOWER(title)=? AND LOWER(artist)=? AND LOWER(album)=? AND pid!=? ORDER BY physical_order", + (lt, la, lb, keep_pid) + ) + dup_rows = cursor.fetchall() + + for dup_pid, dup_title, dup_artist, dup_album, dup_order in dup_rows: + pids_to_remove.append(dup_pid) + removed_info.append({ + "pid": dup_pid, + "title": dup_title, + "artist": dup_artist, + "album": dup_album, + "physical_order": dup_order, + }) + + # Find the physical file for this duplicate + file_path = self._find_track_file(dup_pid, dup_title, dup_artist) + if file_path: + files_to_remove.append(file_path) + + logger.info(f"Found {len(pids_to_remove)} duplicate entries to remove across {len(dup_groups)} groups") + + # Remove from all tables + for pid in pids_to_remove: + cursor.execute("DELETE FROM item_to_container WHERE item_pid = ?", (pid,)) + cursor.execute("DELETE FROM avformat_info WHERE item_pid = ?", (pid,)) + cursor.execute("DELETE FROM store_info WHERE item_pid = ?", (pid,)) + cursor.execute("DELETE FROM item WHERE pid = ?", (pid,)) + + # Clean up orphaned artist/album entries + self._cleanup_orphaned_entries(cursor) + + conn.commit() + + # Delete physical files + for fpath in files_to_remove: + if os.path.exists(fpath): + os.remove(fpath) + logger.info(f"Deleted duplicate file: {fpath}") + + # Update Locations.itdb + for pid in pids_to_remove: + self._delete_location(pid) + + conn.close() + + # Sync databases + self.sync_itunescdb() + self.sync_locations_cbk() + + logger.info(f"Removed {len(removed_info)} duplicate track(s)") + return removed_info + def _find_track_file(self, pid: int, title: str, artist: str) -> Optional[str]: """Find the physical file for a track by scanning Music/FXX/ directories.""" music_base = os.path.join(self.mountpoint, "iPod_Control", "Music") diff --git a/src/main.py b/src/main.py index be9d7f0..ea7adc1 100644 --- a/src/main.py +++ b/src/main.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """ -Main Application Module for iPod Nano Transfer Tool -Provides a GUI for the YouTube Music to iPod Nano transfer tool +Main Application Module for neo-pod-desktop +Provides a GUI for downloading, converting, managing and transferring music to iPod Nano devices """ import os @@ -212,7 +212,7 @@ class MainWindow(QMainWindow): def __init__(self): super().__init__() - self.setWindowTitle("YouTube to iPod Nano") + self.setWindowTitle("neo-pod-desktop") self.setMinimumSize(800, 600) self.worker_thread = None @@ -396,9 +396,9 @@ class MainWindow(QMainWindow): layout.addWidget(self.ready_label) self.library_ready_table = QTableWidget() - self.library_ready_table.setColumnCount(6) + self.library_ready_table.setColumnCount(7) self.library_ready_table.setHorizontalHeaderLabels( - ["#", "Artist", "Title", "Duration", "Genre", "Size"] + ["#", "Artist", "Title", "Album", "Duration", "Genre", "Size"] ) self.library_ready_table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) self.library_ready_table.setSelectionMode(QTableWidget.SelectionMode.ExtendedSelection) @@ -410,10 +410,11 @@ class MainWindow(QMainWindow): header = self.library_ready_table.horizontalHeader() header.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch) header.setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch) + header.setSectionResizeMode(3, QHeaderView.ResizeMode.Stretch) header.setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents) - header.setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents) header.setSectionResizeMode(4, QHeaderView.ResizeMode.ResizeToContents) header.setSectionResizeMode(5, QHeaderView.ResizeMode.ResizeToContents) + header.setSectionResizeMode(6, QHeaderView.ResizeMode.ResizeToContents) layout.addWidget(self.library_ready_table, stretch=2) @@ -499,6 +500,10 @@ class MainWindow(QMainWindow): orphan_button.clicked.connect(self._on_scan_orphans_clicked) layout.addWidget(orphan_button) + dedup_button = QPushButton("Remove Duplicate Tracks") + dedup_button.clicked.connect(self._on_remove_duplicates_clicked) + layout.addWidget(dedup_button) + def _setup_settings_tab(self, tab): layout = QVBoxLayout(tab) @@ -565,9 +570,9 @@ class MainWindow(QMainWindow): layout.addStretch() about_label = QLabel( - "YouTube to iPod Nano Transfer Tool\n" + "neo-pod-desktop\n" "Version 1.0.0\n\n" - "An open-source Python tool that enables direct transfer of YouTube music/playlists to iPod Nano." + "Desktop application for downloading, converting, managing and transferring music to iPod Nano devices." ) about_label.setAlignment(Qt.AlignmentFlag.AlignCenter) layout.addWidget(about_label) @@ -723,12 +728,14 @@ class MainWindow(QMainWindow): duration = info.duration if info else 0 artist = info.artist if info else "Unknown" title = info.title if info else fname + album = info.album if info else "" genre = info.genre if info else "" track_num = info.track_number if info else 0 except Exception: duration = 0 artist = "Unknown" title = fname + album = "" genre = "" track_num = 0 @@ -736,6 +743,7 @@ class MainWindow(QMainWindow): "path": fpath, "title": title, "artist": artist, + "album": album, "duration_s": duration, "size": size, "genre": genre, @@ -760,6 +768,15 @@ class MainWindow(QMainWindow): "track_num": track_num, }) + # Deduplicate by file path (defensive — os.walk shouldn't produce dupes) + seen_paths = set() + deduped_ready = [] + for track in ready: + if track["path"] not in seen_paths: + seen_paths.add(track["path"]) + deduped_ready.append(track) + ready = deduped_ready + self.library_ready = ready self._refresh_library_ui() self._refresh_source_ui(sources) @@ -781,6 +798,7 @@ class MainWindow(QMainWindow): 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"])), @@ -1383,6 +1401,32 @@ class MainWindow(QMainWindow): logger.error(f"Failed to scan for orphaned files: {e}") QMessageBox.warning(self, "Error", f"Failed to scan: {e}") + def _on_remove_duplicates_clicked(self): + if not self.current_mount_point: + QMessageBox.warning(self, "Error", "No iPod device mounted.") + return + + try: + from ipod_nano7_db import Nano7Database + db = Nano7Database(self.current_mount_point) + removed = db.remove_duplicates() + + if not removed: + QMessageBox.information(self, "Scan Complete", "No duplicate tracks found.") + return + + details = f"Found and removed {len(removed)} duplicate entry(ies):\n\n" + for r in removed[:15]: + details += f" {r['artist']} \u2014 {r['title']} ({r['album']})\n" + if len(removed) > 15: + details += f" ... and {len(removed) - 15} more\n" + + QMessageBox.information(self, "Duplicates Removed", details) + self._load_ipod_tracks() + except Exception as e: + logger.error(f"Failed to remove duplicates: {e}") + QMessageBox.warning(self, "Error", f"Failed to remove duplicates: {e}") + def closeEvent(self, event): if self.worker_thread and self.worker_thread.isRunning(): self.worker_thread.stop()