Add track management: view and delete tracks on iPod from Transfer tab

- Add get_all_tracks() to load all tracks with file paths via tag matching
- Add delete_track() to remove tracks from SQLite DB and delete physical files
- Add _cleanup_orphaned_entries() to remove orphaned artist/album records
- Add track count label and 'Remove Selected' button to Transfer tab
- Load existing tracks on device detection
- Confirmation dialog before deleting tracks
- Reload track list after successful transfer
This commit is contained in:
Maksim Totmin 2026-05-31 12:37:13 +07:00
parent 1b4c092a04
commit 47bfdf0011
2 changed files with 335 additions and 4 deletions

View File

@ -435,3 +435,264 @@ class Nano7Database:
tracks = cursor.fetchall() tracks = cursor.fetchall()
conn.close() conn.close()
return tracks return tracks
def get_all_tracks(self) -> list:
"""Get all tracks with full metadata and file paths."""
conn = sqlite3.connect(self.sqlite_path)
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute(
"""
SELECT pid, title, artist, album, album_artist,
total_time_ms, track_number, disc_number,
physical_order, date_modified, year
FROM item
ORDER BY physical_order
"""
)
rows = cursor.fetchall()
conn.close()
tracks = []
for row in rows:
track = {
"pid": row["pid"],
"title": row["title"] or "Unknown",
"artist": row["artist"] or "Unknown Artist",
"album": row["album"] or "",
"album_artist": row["album_artist"] or "",
"duration_ms": row["total_time_ms"] or 0,
"track_number": row["track_number"] or 0,
"disc_number": row["disc_number"] or 0,
"physical_order": row["physical_order"] or 0,
"date_modified": row["date_modified"] or 0,
"year": row["year"] or 0,
"ipod_path": None,
"file_path": None,
}
tracks.append(track)
# Resolve physical file paths by scanning Music/FXX/ directories
# and matching tags to tracks
track_lookup = {}
for t in tracks:
key = (t["title"].lower(), t["artist"].lower(), t["duration_ms"])
track_lookup[key] = t
music_base = os.path.join(self.mountpoint, "iPod_Control", "Music")
if os.path.exists(music_base):
for folder in sorted(os.listdir(music_base)):
folder_path = os.path.join(music_base, folder)
if not os.path.isdir(folder_path):
continue
for fname in os.listdir(folder_path):
fpath = os.path.join(folder_path, fname)
if not os.path.isfile(fpath):
continue
ext = os.path.splitext(fname)[1].lower()
if ext not in (".mp3", ".m4a", ".aac", ".mp4"):
continue
# Try to match by tags
try:
audio = MutagenFile(fpath)
if audio is None or audio.tags is None:
continue
# Get title
file_title = ""
file_artist = ""
if ext == ".mp3":
try:
easy = EasyID3(fpath)
if "title" in easy:
file_title = str(easy["title"][0])
if "artist" in easy:
file_artist = str(easy["artist"][0])
except Exception:
pass
elif ext in (".m4a", ".aac", ".mp4"):
from mutagen.mp4 import MP4
tags = audio.tags
if "\xa9nam" in tags:
file_title = str(tags["\xa9nam"][0])
if "\xa9ART" in tags:
file_artist = str(tags["\xa9ART"][0])
else:
tags = audio.tags
try:
if "title" in tags:
val = tags["title"]
file_title = str(val[0] if isinstance(val, list) else val)
if "artist" in tags:
val = tags["artist"]
file_artist = str(val[0] if isinstance(val, list) else val)
except (ValueError, TypeError):
pass
duration_ms = int(audio.info.length * 1000) if hasattr(audio, "info") and audio.info.length else 0
key = (file_title.lower(), file_artist.lower(), duration_ms)
if key in track_lookup:
track = track_lookup[key]
track["ipod_path"] = f":iPod_Control:Music:{folder}:{fname}"
track["file_path"] = fpath
del track_lookup[key]
except Exception:
pass
# For unmatched tracks, try to find file by ipod_path pattern
for t in tracks:
if t["file_path"]:
continue
# Try to find by scanning for any audio file in FXX dirs
for folder in sorted(os.listdir(music_base)):
folder_path = os.path.join(music_base, folder)
if not os.path.isdir(folder_path):
continue
for fname in os.listdir(folder_path):
fpath = os.path.join(folder_path, fname)
if not os.path.isfile(fpath):
continue
ext = os.path.splitext(fname)[1].lower()
if ext in (".mp3", ".m4a", ".aac", ".mp4"):
t["ipod_path"] = f":iPod_Control:Music:{folder}:{fname}"
t["file_path"] = fpath
break
if t["file_path"]:
break
return tracks
def delete_track(self, pid: int) -> bool:
"""Delete a track from the iPod database and remove its physical file."""
try:
conn = sqlite3.connect(self.sqlite_path)
cursor = conn.cursor()
# Get the track info first (for file path)
cursor.execute(
"SELECT title, artist, album FROM item WHERE pid = ?",
(pid,),
)
row = cursor.fetchone()
if row is None:
conn.close()
logger.warning(f"Track pid={pid} not found in database")
return False
title, artist, album = row
# Find the physical file by matching title+artist in tags
file_path = self._find_track_file(pid, title, artist)
# Remove from item_to_container
cursor.execute("DELETE FROM item_to_container WHERE item_pid = ?", (pid,))
# Remove from avformat_info
cursor.execute("DELETE FROM avformat_info WHERE item_pid = ?", (pid,))
# Remove from store_info
cursor.execute("DELETE FROM store_info WHERE item_pid = ?", (pid,))
# Remove from item
cursor.execute("DELETE FROM item WHERE pid = ?", (pid,))
# Clean up orphaned artist/album entries
self._cleanup_orphaned_entries(cursor)
conn.commit()
conn.close()
# Remove physical file
if file_path and os.path.exists(file_path):
os.remove(file_path)
logger.info(f"Deleted physical file: {file_path}")
logger.info(f"Deleted track pid={pid}: {artist} - {title}")
return True
except Exception as e:
logger.error(f"Failed to delete track pid={pid}: {e}")
return False
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")
if not os.path.exists(music_base):
return None
title_lower = (title or "").lower()
artist_lower = (artist or "").lower()
for folder in sorted(os.listdir(music_base)):
folder_path = os.path.join(music_base, folder)
if not os.path.isdir(folder_path):
continue
for fname in os.listdir(folder_path):
fpath = os.path.join(folder_path, fname)
if not os.path.isfile(fpath):
continue
ext = os.path.splitext(fname)[1].lower()
if ext not in (".mp3", ".m4a", ".aac", ".mp4"):
continue
try:
audio = MutagenFile(fpath)
if audio is None or audio.tags is None:
continue
file_title = ""
file_artist = ""
if ext == ".mp3":
try:
easy = EasyID3(fpath)
if "title" in easy:
file_title = str(easy["title"][0])
if "artist" in easy:
file_artist = str(easy["artist"][0])
except Exception:
pass
elif ext in (".m4a", ".aac", ".mp4"):
tags = audio.tags
if "\xa9nam" in tags:
file_title = str(tags["\xa9nam"][0])
if "\xa9ART" in tags:
file_artist = str(tags["\xa9ART"][0])
else:
tags = audio.tags
try:
if "title" in tags:
val = tags["title"]
file_title = str(val[0] if isinstance(val, list) else val)
if "artist" in tags:
val = tags["artist"]
file_artist = str(val[0] if isinstance(val, list) else val)
except (ValueError, TypeError):
pass
if file_title.lower() == title_lower and file_artist.lower() == artist_lower:
return fpath
except Exception:
continue
return None
def _cleanup_orphaned_entries(self, cursor):
"""Remove artist/album records with no remaining tracks."""
# Remove artists with no tracks referencing them
cursor.execute(
"""
DELETE FROM artist
WHERE pid NOT IN (SELECT DISTINCT artist_pid FROM item WHERE artist_pid IS NOT NULL)
"""
)
# Remove albums with no tracks referencing them
cursor.execute(
"""
DELETE FROM album
WHERE pid NOT IN (SELECT DISTINCT album_pid FROM item WHERE album_pid IS NOT NULL)
"""
)

View File

@ -428,6 +428,10 @@ class MainWindow(QMainWindow):
self.device_info_label = QLabel("No device selected") self.device_info_label = QLabel("No device selected")
layout.addWidget(self.device_info_label) layout.addWidget(self.device_info_label)
# Track count
self.track_count_label = QLabel("Tracks on device: 0")
layout.addWidget(self.track_count_label)
# Transfer button # Transfer button
self.transfer_button = QPushButton("Transfer to iPod") self.transfer_button = QPushButton("Transfer to iPod")
self.transfer_button.clicked.connect(self._on_transfer_clicked) self.transfer_button.clicked.connect(self._on_transfer_clicked)
@ -448,8 +452,14 @@ class MainWindow(QMainWindow):
layout.addWidget(transferred_list_label) layout.addWidget(transferred_list_label)
self.transferred_list = QListWidget() self.transferred_list = QListWidget()
self.transferred_list.setSelectionMode(QListWidget.SelectionMode.ExtendedSelection)
layout.addWidget(self.transferred_list) layout.addWidget(self.transferred_list)
# Remove selected button
remove_button = QPushButton("Remove Selected from iPod")
remove_button.clicked.connect(self._on_remove_tracks_clicked)
layout.addWidget(remove_button)
def _setup_settings_tab(self, tab): def _setup_settings_tab(self, tab):
"""Set up the settings tab""" """Set up the settings tab"""
layout = QVBoxLayout(tab) layout = QVBoxLayout(tab)
@ -730,6 +740,9 @@ class MainWindow(QMainWindow):
# Enable transfer button # Enable transfer button
self.transfer_button.setEnabled(True) self.transfer_button.setEnabled(True)
# Load existing tracks from iPod
self._load_ipod_tracks()
else: else:
self.device_info_label.setText( self.device_info_label.setText(
f"Device: {self.ipod_devices[0]['name']}\n" f"Device: {self.ipod_devices[0]['name']}\n"
@ -789,10 +802,8 @@ class MainWindow(QMainWindow):
if success: if success:
self.transfer_status.setText(message) self.transfer_status.setText(message)
# Update transferred list # Reload track list from iPod
for track, path in result: self._load_ipod_tracks()
item = QListWidgetItem(f"{track.artist} - {track.title}")
self.transferred_list.addItem(item)
QMessageBox.information(self, "Transfer Complete", message) QMessageBox.information(self, "Transfer Complete", message)
else: else:
@ -802,6 +813,65 @@ class MainWindow(QMainWindow):
# Clean up worker thread # Clean up worker thread
self.worker_thread = None self.worker_thread = None
def _load_ipod_tracks(self):
"""Load existing tracks from iPod database into transferred_list"""
if not self.current_mount_point:
return
try:
from ipod_nano7_db import Nano7Database
db = Nano7Database(self.current_mount_point)
tracks = db.get_all_tracks()
self.transferred_list.clear()
for track in tracks:
display = f"{track['artist']} - {track['title']}"
if track["album"]:
display += f" ({track['album']})"
item = QListWidgetItem(display)
item.setData(Qt.ItemDataRole.UserRole, track)
self.transferred_list.addItem(item)
self.track_count_label.setText(f"Tracks on device: {len(tracks)}")
except Exception as e:
logger.error(f"Failed to load iPod tracks: {e}")
self.track_count_label.setText("Tracks on device: unknown")
def _on_remove_tracks_clicked(self):
"""Remove selected tracks from iPod with confirmation"""
selected = self.transferred_list.selectedItems()
if not selected:
QMessageBox.information(self, "Info", "Select tracks to remove")
return
count = len(selected)
reply = QMessageBox.question(
self, "Confirm Delete",
f"Remove {count} track(s) from iPod?\nThis will permanently delete the files from the device.",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
)
if reply != QMessageBox.StandardButton.Yes:
return
try:
from ipod_nano7_db import Nano7Database
db = Nano7Database(self.current_mount_point)
removed = 0
for item in selected:
track_data = item.data(Qt.ItemDataRole.UserRole)
if track_data and "pid" in track_data:
success = db.delete_track(track_data["pid"])
if success:
removed += 1
# Reload list
self._load_ipod_tracks()
QMessageBox.information(self, "Done", f"Successfully removed {removed} track(s) from iPod")
except Exception as e:
logger.error(f"Failed to remove tracks: {e}")
QMessageBox.warning(self, "Error", f"Failed to remove tracks: {e}")
def closeEvent(self, event): def closeEvent(self, event):
"""Handle window close event""" """Handle window close event"""
# Stop worker thread if running # Stop worker thread if running