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:
parent
1b4c092a04
commit
47bfdf0011
@ -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)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|||||||
78
src/main.py
78
src/main.py
@ -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,7 +452,13 @@ 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"""
|
||||||
@ -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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user