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:
+2
-2
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user