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.playlist_manager = PlaylistManager()
self._sidebar_visible = True self._sidebar_visible = True
self._pending_playlist_id: str = ""
self._pending_playlist_tracks: list = []
self._saved_volume: int = 80 self._saved_volume: int = 80
self._saved_track_path: str = "" 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_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.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.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): def _on_sidebar_item_selected(self, kind: str, value: str):
if kind == "tab": if kind == "tab":
@ -197,6 +201,55 @@ class MainWindow(QMainWindow):
self.stack.setCurrentIndex(0) self.stack.setCurrentIndex(0)
self.library_tab.transfer_tracks_by_paths(paths) 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): def _load_playlists_into_sidebar(self):
for pl in self.playlist_manager.get_all(): for pl in self.playlist_manager.get_all():
self.sidebar.add_playlist_item(pl.id, pl.name) self.sidebar.add_playlist_item(pl.id, pl.name)

View File

@ -32,7 +32,7 @@ import sqlite3
import struct import struct
import subprocess import subprocess
import time import time
from typing import Any, Optional from typing import Any, Callable, Optional
from mutagen import File as MutagenFile from mutagen import File as MutagenFile
from mutagen.easyid3 import EasyID3 from mutagen.easyid3 import EasyID3
@ -627,6 +627,114 @@ class Nano7Database:
deleted += 1 deleted += 1
return deleted 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 ────────────────────── # ── sync: regenerate everything via iOpenPod ──────────────────────
def sync_itunescdb(self) -> None: def sync_itunescdb(self) -> None:
@ -694,7 +802,9 @@ class Nano7Database:
return return
self._merge_play_stats(scanned) 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() self._cleanup_play_counts_file()
def find_track_by_content_hash(self, content_hash: str) -> dict | None: def find_track_by_content_hash(self, content_hash: str) -> dict | None:
@ -901,7 +1011,9 @@ class Nano7Database:
self, self,
scanned: list[dict], scanned: list[dict],
artwork_overrides: dict[str, object] | None = None, 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. """Build artwork, convert to TrackInfo, write iTunesDB + SQLite.
Args: Args:
@ -911,6 +1023,15 @@ class Nano7Database:
artwork should come from a local file instead of the iPod file. artwork should come from a local file instead of the iPod file.
Use the ``_REMOVE_ARTWORK`` sentinel to explicitly remove Use the ``_REMOVE_ARTWORK`` sentinel to explicitly remove
artwork for a track. Omit a key to keep existing iPod artwork. 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 {} artwork_overrides = artwork_overrides or {}
# 1. Prepare artwork for all tracks # 1. Prepare artwork for all tracks
@ -970,6 +1091,20 @@ class Nano7Database:
tracks = _scanned_tracks_to_iop(self.mountpoint, scanned, artwork_map) tracks = _scanned_tracks_to_iop(self.mountpoint, scanned, artwork_map)
logger.info("Built %d iOpenPod TrackInfo objects", len(tracks)) 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 # 3. Determine capabilities for this device
ipod_device = _import_iop("ipod_device") ipod_device = _import_iop("ipod_device")
capabilities = None capabilities = None
@ -998,10 +1133,11 @@ class Nano7Database:
backup=True, backup=True,
capabilities=capabilities, capabilities=capabilities,
master_playlist_name="iPod", master_playlist_name="iPod",
playlists=extra_playlists or None,
) )
if not ok: if not ok:
logger.error("iTunesDB write failed") logger.error("iTunesDB write failed")
return return False
logger.info("iTunesDB written (%d tracks)", len(tracks)) logger.info("iTunesDB written (%d tracks)", len(tracks))
# 5. Extract db_pid from the binary DB for SQLite cross-reference # 5. Extract db_pid from the binary DB for SQLite cross-reference
@ -1029,13 +1165,18 @@ class Nano7Database:
capabilities=capabilities, capabilities=capabilities,
firewire_id=firewire_id, firewire_id=firewire_id,
backup=True, backup=True,
playlists=extra_playlists or None,
) )
if sqlite_ok: if sqlite_ok:
logger.info("SQLite databases written") logger.info("SQLite databases written")
else: else:
logger.error("SQLite database write failed") logger.error("SQLite database write failed")
return False
except Exception as e: except Exception as e:
logger.exception("SQLite write error: %s", e) logger.exception("SQLite write error: %s", e)
return False
return True
def _write_empty_databases(self) -> None: def _write_empty_databases(self) -> None:
"""Write minimal empty databases when no tracks exist.""" """Write minimal empty databases when no tracks exist."""

View File

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

View File

@ -44,6 +44,7 @@ class WorkerThread(QThread):
"delete_tracks": self._run_delete_tracks, "delete_tracks": self._run_delete_tracks,
"remove_duplicates": self._run_remove_duplicates, "remove_duplicates": self._run_remove_duplicates,
"sync_metadata": self._run_sync_metadata, "sync_metadata": self._run_sync_metadata,
"create_playlist": self._run_create_playlist,
} }
handler = handlers.get(self.task_type) handler = handlers.get(self.task_type)
if handler: if handler:
@ -362,6 +363,26 @@ class WorkerThread(QThread):
self.progress_signal.emit(100, msg) self.progress_signal.emit(100, msg)
self.finished_signal.emit(synced > 0, msg, {"synced": synced, "errors": errors}) 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): def stop(self):
self.is_running = False self.is_running = False
self.wait() self.wait()