fix: correct playlist creation on iPod (real db_track_id, metadata fallback, Settings hover fix)

This commit is contained in:
Maksim Totmin 2026-06-01 21:05:39 +07:00
parent 8861e55f32
commit b7367e63c4
4 changed files with 279 additions and 6 deletions

View File

@ -48,6 +48,8 @@ class MainWindow(QMainWindow):
self.playlist_manager = PlaylistManager()
self._sidebar_visible = True
self._pending_playlist_id: str = ""
self._pending_playlist_tracks: list = []
self._saved_volume: int = 80
self._saved_track_path: str = ""
@ -141,7 +143,9 @@ class MainWindow(QMainWindow):
self.sidebar.tracks_dropped_on_playlist.connect(self._on_tracks_dropped_on_playlist)
self.sidebar.tracks_dropped_for_nav_transfer.connect(self._on_tracks_dropped_for_nav_transfer)
self.sidebar.playlist_dropped_for_nav_transfer.connect(self._on_playlist_dropped_for_nav_transfer)
self.ipod_tab.tracks_dropped_for_transfer.connect(self._on_tracks_dropped_for_transfer)
self.library_tab.transfer_finished.connect(self._on_transfer_finished_for_playlist)
def _on_sidebar_item_selected(self, kind: str, value: str):
if kind == "tab":
@ -197,6 +201,55 @@ class MainWindow(QMainWindow):
self.stack.setCurrentIndex(0)
self.library_tab.transfer_tracks_by_paths(paths)
def _on_playlist_dropped_for_nav_transfer(self, playlist_id: str):
playlist = self.playlist_manager.get_by_id(playlist_id)
if not playlist or not playlist.track_paths:
return
self.stack.setCurrentIndex(0)
self._pending_playlist_id = playlist_id
self._pending_playlist_tracks = list(playlist.track_paths)
self.library_tab.transfer_tracks_by_paths(playlist.track_paths)
def _on_transfer_finished_for_playlist(self):
playlist_id = self._pending_playlist_id
track_paths = self._pending_playlist_tracks
self._pending_playlist_id = ""
self._pending_playlist_tracks = []
if not playlist_id or not track_paths:
return
mount_point = getattr(self.ipod_tab, "current_mount_point", None)
if not mount_point:
self.statusBar().showMessage("Cannot create playlist: iPod not mounted", 5000)
return
playlist = self.playlist_manager.get_by_id(playlist_id)
if not playlist:
return
self.statusBar().showMessage(f"Creating playlist '{playlist.name}' on iPod...")
from worker import WorkerThread
self._pl_worker = WorkerThread(
task_type="create_playlist",
mount_point=mount_point,
playlist_name=playlist.name,
track_paths=track_paths,
)
self._pl_worker.finished_signal.connect(self._on_playlist_creation_finished)
self._pl_worker.start()
def _on_playlist_creation_finished(self, success, message, result):
self.statusBar().showMessage(message, 8000)
try:
self._pl_worker.quit()
self._pl_worker.wait()
except Exception:
pass
self._pl_worker = None
def _load_playlists_into_sidebar(self):
for pl in self.playlist_manager.get_all():
self.sidebar.add_playlist_item(pl.id, pl.name)

View File

@ -32,7 +32,7 @@ import sqlite3
import struct
import subprocess
import time
from typing import Any, Optional
from typing import Any, Callable, Optional
from mutagen import File as MutagenFile
from mutagen.easyid3 import EasyID3
@ -627,6 +627,114 @@ class Nano7Database:
deleted += 1
return deleted
# ── playlist creation ────────────────────────────────────────────
def create_playlist(self, name: str, track_paths: list[str]) -> bool:
"""Create a user playlist on the iPod.
Two-pass matching: content hash first, then title+artist+album
fallback. Only tracks already present on the iPod will be
included in the playlist.
Args:
name: Playlist name.
track_paths: Local file paths of the tracks to include.
Returns:
True if the playlist was created, False otherwise.
"""
scanned = self._scan_ipod_files()
if not scanned:
logger.warning("No tracks on iPod — cannot create playlist")
return False
playlist_created = False
self._merge_play_stats(scanned)
def _build_pl_cb(scanned, tracks):
"""Build playlist using real db_track_id from TrackInfo."""
nonlocal playlist_created
try:
IopPlaylistInfo = _import_iop(
"iTunesDB_Writer.mhyp_writer"
).PlaylistInfo
except Exception as e:
logger.exception("Failed to import PlaylistInfo: %s", e)
return None
# Pass 1: content_hash → db_track_id
hash_to_db_id: dict[str, int] = {}
for i, s in enumerate(scanned):
ch = s.get("content_hash", "")
if ch:
hash_to_db_id[ch] = tracks[i].db_track_id
found_ids: list[int] = []
unmatched: list[str] = []
for path in track_paths:
if not os.path.exists(path):
continue
ch = content_hash(path)
db_id = hash_to_db_id.get(ch)
if db_id is not None:
found_ids.append(db_id)
else:
unmatched.append(path)
hash_matched_count = len(found_ids)
# Pass 2: metadata fallback
if unmatched:
scanned_meta = []
for i, s in enumerate(scanned):
scanned_meta.append((
s.get("title", "").lower(),
s.get("artist", "").lower(),
(s.get("album") or "").lower(),
tracks[i].db_track_id,
))
found_set = set(found_ids)
for path in unmatched:
tags = _read_audio_tags(path)
title = (tags.get("title") or "").lower()
artist = (tags.get("artist") or "").lower()
album = (tags.get("album") or "").lower()
if not title or not artist:
continue
for s_title, s_artist, s_album, db_id in scanned_meta:
if (db_id not in found_set
and s_title == title
and s_artist == artist
and s_album == album):
found_ids.append(db_id)
found_set.add(db_id)
break
if not found_ids:
logger.warning(
"No tracks from playlist '%s' found on iPod",
name,
)
return None
matched_by_hash = hash_matched_count
matched_by_meta = len(found_ids) - hash_matched_count
logger.info(
"Playlist '%s': %d track(s) matched (%d by hash, %d by metadata)",
name, len(found_ids), matched_by_hash, matched_by_meta,
)
playlist_created = True
return [IopPlaylistInfo(name=name, track_ids=found_ids)]
db_ok = self._write_databases_from_tracks(
scanned, build_extra_playlists=_build_pl_cb,
)
self._cleanup_play_counts_file()
return db_ok and playlist_created
# ── sync: regenerate everything via iOpenPod ──────────────────────
def sync_itunescdb(self) -> None:
@ -694,7 +802,9 @@ class Nano7Database:
return
self._merge_play_stats(scanned)
self._write_databases_from_tracks(scanned)
if not self._write_databases_from_tracks(scanned):
logger.error("iTunesDB sync failed — play counts not cleaned up")
return
self._cleanup_play_counts_file()
def find_track_by_content_hash(self, content_hash: str) -> dict | None:
@ -901,7 +1011,9 @@ class Nano7Database:
self,
scanned: list[dict],
artwork_overrides: dict[str, object] | None = None,
) -> None:
extra_playlists: list | None = None,
build_extra_playlists: Callable | None = None,
) -> bool:
"""Build artwork, convert to TrackInfo, write iTunesDB + SQLite.
Args:
@ -911,6 +1023,15 @@ class Nano7Database:
artwork should come from a local file instead of the iPod file.
Use the ``_REMOVE_ARTWORK`` sentinel to explicitly remove
artwork for a track. Omit a key to keep existing iPod artwork.
extra_playlists: Pre-built list of PlaylistInfo (ignored when
build_extra_playlists is provided).
build_extra_playlists: Optional callable(scanned, tracks) list
of PlaylistInfo. Called after TrackInfo conversion and
db_track_id generation, so the callback can use real
db_track_id values from TrackInfo objects.
Returns:
True if databases were written successfully.
"""
artwork_overrides = artwork_overrides or {}
# 1. Prepare artwork for all tracks
@ -970,6 +1091,20 @@ class Nano7Database:
tracks = _scanned_tracks_to_iop(self.mountpoint, scanned, artwork_map)
logger.info("Built %d iOpenPod TrackInfo objects", len(tracks))
# 2b. Pre-generate db_track_id for all tracks so callbacks can use them
generate_db_track_id = _import_iop("iTunesDB_Writer.mhit_writer").generate_db_track_id
for track in tracks:
if track.db_track_id == 0:
track.db_track_id = generate_db_track_id()
# 2c. Build extra playlists via callback if provided
if build_extra_playlists is not None:
result = build_extra_playlists(scanned, tracks)
if result is not None:
extra_playlists = result
else:
extra_playlists = None
# 3. Determine capabilities for this device
ipod_device = _import_iop("ipod_device")
capabilities = None
@ -998,10 +1133,11 @@ class Nano7Database:
backup=True,
capabilities=capabilities,
master_playlist_name="iPod",
playlists=extra_playlists or None,
)
if not ok:
logger.error("iTunesDB write failed")
return
return False
logger.info("iTunesDB written (%d tracks)", len(tracks))
# 5. Extract db_pid from the binary DB for SQLite cross-reference
@ -1029,13 +1165,18 @@ class Nano7Database:
capabilities=capabilities,
firewire_id=firewire_id,
backup=True,
playlists=extra_playlists or None,
)
if sqlite_ok:
logger.info("SQLite databases written")
else:
logger.error("SQLite database write failed")
return False
except Exception as e:
logger.exception("SQLite write error: %s", e)
return False
return True
def _write_empty_databases(self) -> None:
"""Write minimal empty databases when no tracks exist."""

View File

@ -27,6 +27,14 @@ class PlaylistDropListWidget(QListWidget):
self.setAcceptDrops(True)
self._hovered_item = None
def mimeData(self, items):
mime = super().mimeData(items)
if items:
playlist_id = items[0].data(Qt.ItemDataRole.UserRole)
if playlist_id:
mime.setData("application/x-neopod-playlist", playlist_id.encode())
return mime
def dragEnterEvent(self, event: QDragEnterEvent):
if event.source() is not self and event.mimeData().hasUrls():
event.acceptProposedAction()
@ -83,6 +91,7 @@ class PlaylistDropListWidget(QListWidget):
class NavDropListWidget(QListWidget):
"""QListWidget that accepts external track drops on the iPod nav item."""
tracks_dropped_for_transfer = pyqtSignal(list)
playlist_dropped_for_transfer = pyqtSignal(str)
def __init__(self, parent=None):
super().__init__(parent)
@ -95,7 +104,19 @@ class NavDropListWidget(QListWidget):
return item
return None
def _repolish(self):
self.style().unpolish(self)
self.style().polish(self)
def dragEnterEvent(self, event: QDragEnterEvent):
if event.mimeData().hasFormat("application/x-neopod-playlist"):
self.setProperty("dndActive", True)
self._repolish()
pos = event.position().toPoint()
if self._target_item_at(pos):
event.acceptProposedAction()
return
# Let default handler accept so dragMoveEvent still fires
if event.source() is not self and event.mimeData().hasUrls():
pos = event.position().toPoint()
if self._target_item_at(pos):
@ -104,6 +125,7 @@ class NavDropListWidget(QListWidget):
super().dragEnterEvent(event)
def dragMoveEvent(self, event: QDragMoveEvent):
accept = False
if event.source() is not self and event.mimeData().hasUrls():
pos = event.position().toPoint()
item = self._target_item_at(pos)
@ -115,15 +137,34 @@ class NavDropListWidget(QListWidget):
item.setBackground(bg)
if item:
event.acceptProposedAction()
return
super().dragMoveEvent(event)
accept = True
if not accept and event.mimeData().hasFormat("application/x-neopod-playlist"):
pos = event.position().toPoint()
item = self._target_item_at(pos)
if item != self._hovered_item:
self._clear_hover()
if item:
self._hovered_item = item
bg = self.palette().color(self.palette().ColorRole.Highlight)
item.setBackground(bg)
if item:
event.acceptProposedAction()
accept = True
if not accept:
event.ignore()
def dragLeaveEvent(self, event: QDragLeaveEvent):
self._clear_hover()
if self.property("dndActive"):
self.setProperty("dndActive", False)
self._repolish()
super().dragLeaveEvent(event)
def dropEvent(self, event: QDropEvent):
self._clear_hover()
if self.property("dndActive"):
self.setProperty("dndActive", False)
self._repolish()
if event.source() is not self and event.mimeData().hasUrls():
pos = event.position().toPoint()
if self._target_item_at(pos):
@ -133,6 +174,14 @@ class NavDropListWidget(QListWidget):
self.tracks_dropped_for_transfer.emit(paths)
event.acceptProposedAction()
return
if event.mimeData().hasFormat("application/x-neopod-playlist"):
pos = event.position().toPoint()
if self._target_item_at(pos):
playlist_id = bytes(event.mimeData().data("application/x-neopod-playlist")).decode()
if playlist_id:
self.playlist_dropped_for_transfer.emit(playlist_id)
event.acceptProposedAction()
return
super().dropEvent(event)
def _clear_hover(self):
@ -146,6 +195,7 @@ class SidebarWidget(QWidget):
playlist_selected = pyqtSignal(str)
tracks_dropped_on_playlist = pyqtSignal(str, list)
tracks_dropped_for_nav_transfer = pyqtSignal(list)
playlist_dropped_for_nav_transfer = pyqtSignal(str)
def __init__(self, parent=None):
super().__init__(parent)
@ -205,6 +255,9 @@ class SidebarWidget(QWidget):
QListWidget::item:hover:!selected {{
background: {hl_light_name};
}}
#navList[dndActive="true"]::item:hover:!selected {{
background: transparent;
}}
""")
def _build_ui(self):
@ -292,6 +345,7 @@ class SidebarWidget(QWidget):
layout.addWidget(line2)
self._nav_list = NavDropListWidget()
self._nav_list.setObjectName("navList")
items = [
("🎸 iPod", "ipod"),
("⚙️ Settings", "settings"),
@ -303,6 +357,7 @@ class SidebarWidget(QWidget):
self._nav_list.addItem(item)
self._nav_list.itemClicked.connect(self._on_nav_item_clicked)
self._nav_list.tracks_dropped_for_transfer.connect(self._on_tracks_dropped_for_nav_transfer)
self._nav_list.playlist_dropped_for_transfer.connect(self._on_playlist_dropped_for_transfer)
layout.addWidget(self._nav_list)
self._playlist_items: dict = {}
@ -396,6 +451,9 @@ class SidebarWidget(QWidget):
def _on_tracks_dropped_for_nav_transfer(self, paths: list):
self.tracks_dropped_for_nav_transfer.emit(paths)
def _on_playlist_dropped_for_transfer(self, playlist_id: str):
self.playlist_dropped_for_nav_transfer.emit(playlist_id)
def _on_playlists_reordered(self):
order = self.get_playlist_order_ids()
self.item_selected.emit("playlist_reorder", "|".join(order))

View File

@ -44,6 +44,7 @@ class WorkerThread(QThread):
"delete_tracks": self._run_delete_tracks,
"remove_duplicates": self._run_remove_duplicates,
"sync_metadata": self._run_sync_metadata,
"create_playlist": self._run_create_playlist,
}
handler = handlers.get(self.task_type)
if handler:
@ -362,6 +363,26 @@ class WorkerThread(QThread):
self.progress_signal.emit(100, msg)
self.finished_signal.emit(synced > 0, msg, {"synced": synced, "errors": errors})
def _run_create_playlist(self):
mount_point = self.kwargs.get("mount_point")
name = self.kwargs.get("playlist_name", "")
track_paths = self.kwargs.get("track_paths", [])
if not mount_point or not name:
self.finished_signal.emit(False, "Missing parameters for playlist creation", None)
return
from ipod_nano7_db import Nano7Database
db = Nano7Database(mount_point)
self.progress_signal.emit(0, f"Creating playlist '{name}' on iPod...")
success = db.create_playlist(name, track_paths)
if success:
self.finished_signal.emit(True, f"Playlist '{name}' created on iPod", None)
else:
self.finished_signal.emit(False, f"Failed to create playlist '{name}'", None)
def stop(self):
self.is_running = False
self.wait()