Rebrand project to neo-pod-desktop with updated description and improvements

- Rename project from 'youtube-to-ipod-nano' to 'neo-pod-desktop' across all files
- Update description: 'Desktop application for downloading, converting,
  managing and transferring music to iPod Nano devices'
- Update setup.py: name, description, url, entry_points, author
- Update run.py: docstring and argparse description
- Update src/__init__.py: module docstring
- Update src/main.py: docstring, window title, about label
- Update src/cli.py: docstring, class docstring, argparse description
- Update README.md: title and project description
- Improve duplicate track detection in ipod_nano7_db.py:
  case-insensitive matching, bulk removal with file cleanup
This commit is contained in:
Maksim Totmin
2026-05-31 16:19:15 +07:00
parent a1881df910
commit 885a401ad0
7 changed files with 168 additions and 29 deletions
+2 -2
View File
@@ -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"
+4 -4
View File
@@ -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
)
+99 -4
View File
@@ -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")
+52 -8
View File
@@ -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()